//! # app — Main TUI application
//!
//! WHY ratatui: Native terminal UI with 60+ FPS rendering, <5MB memory.
//! Replaces Python's prompt_toolkit + Rich with a unified Rust stack.
//!
//! ```text
//!  ┌─────────────────────────────────────────┐
//!  │               Output Area               │  ← scrollable, markdown-rendered
//!  │  Shows assistant responses, tool output, │
//!  │  system messages, and errors.            │
//!  ├─────────────────────────────────────────┤
//!  │ ⠋ Thinking ·  🦀 model │ 1.2k │ $0.02 │  ← status bar + spinner
//!  ├─────────────────────────────────────────┤
//!  │ ┌ /comp│pletion ─────────────────┐     │  ← completion overlay
//!  │ │  /compress                      │     │
//!  │ │  /config                        │     │
//!  │ └────────────────────────────────┘     │
//!  │ > user input here...                    │  ← tui-textarea (multi-line)
//!  └─────────────────────────────────────────┘
//! ```
//!
//! # Phase 9 UX/UI Overhaul
//!
//! - tui-textarea replaces manual String buffer (multi-line, unicode-safe, readline shortcuts)
//! - Tab completion overlay for 42+ slash commands with fuzzy matching
//! - Animated braille spinner in status bar during agent processing
//! - Markdown rendering in output (headers, bold, code blocks with │ prefix)
//! - Auto-updating status bar (tokens, cost, model) after each response
//! - Input line highlighting (cyan for valid commands, red for invalid)
//! - Fish-style ghost text from input history

use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::{self, BufRead, Write};
use std::sync::Arc;
use std::time::{Duration, Instant};

use chrono::TimeZone;
use clap::Parser;
use crossterm::event::{
    self, Event, KeyCode, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseButton,
    MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    prelude::*,
    text::{Line, Span, Text},
    widgets::{
        Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
        ScrollbarState, Wrap,
    },
};
use tokio::sync::mpsc;
use tui_textarea::{CursorMove, Scrolling, TextArea, WrapMode};
use unicode_width::UnicodeWidthStr;

use crate::cli_args::CliArgs;
use crate::commands::{CommandRegistry, CommandResult, ToolManagerMode, gateway_commands_page};
use crate::edit_diff::{LocalEditSnapshot, capture_local_edit_snapshot, render_edit_diff_lines};
use crate::fuzzy_selector::{FuzzyItem, FuzzySelector};
use crate::gateway_browser::{
    GatewayPlatformEntry, build_gateway_platform_entries, supports_allowlist, supports_home_channel,
};
use crate::gateway_catalog::collect_platform_diagnostics;
use crate::image_models as cli_image_models;
use crate::logging::{
    LogFileInfo, LogLevelSetting, default_tail_lines, effective_log_level, format_log_size,
    format_relative_time, list_log_files, persist_log_level, read_last_lines,
    reload_runtime_log_level, tail_preview,
};
use crate::markdown_render;
use crate::mcp_support;
use crate::plugin_toggle::{
    PluginCheckState, PluginScope, PluginToggleEntry, plugin_toggle_status_line,
};
use crate::profile::{ProfileManager, ProfileSummary};
use crate::runtime::{
    build_agent, build_tool_registry_with_mcp_discovery, load_runtime, open_state_db,
};
use crate::theme::{SkinConfig, Theme, palette as P};
use crate::tool_display::{
    DisplayWidths, build_context_gauge, build_subagent_done_line_width,
    build_subagent_running_line_width, build_tool_done_line_width, build_tool_running_line_width,
    build_tool_running_line_width_elapsed, build_tool_verbose_lines_width, tool_action_verb,
    tool_category, tool_icon, tool_signature, tool_status_preview, tool_status_preview_width,
};
use crate::vision_models::{
    available_vision_model_options_with_dynamic, canonical_provider, current_model_supports_vision,
    parse_selection_spec,
};
use edgecrab_core::{
    Agent, DiscoveryAvailability, DiscoverySource, IsolatedAgentOptions, ModelCatalog,
    ProviderModels, ToolProgressMode, discover_multiple, discover_provider_models,
    discovery_provider_statuses, live_discovery_availability, live_discovery_providers,
    merge_grouped_catalog_with_dynamic, normalize_discovery_provider,
};
use edgecrab_tools::registry::{ToolHandler, ToolInventoryEntry};
use edgecrab_tools::tools::transcribe::TranscribeAudioTool;
use edgecrab_tools::tools::tts::{TextToSpeechTool, sanitize_text_for_tts};
use edgecrab_tools::{AppConfigRef, ToolContext};
#[cfg(test)]
use edgequake_llm::ProviderFactory;
use tokio_util::sync::CancellationToken;

const KEYBOARD_PROTOCOL_WARMUP: Duration = Duration::from_millis(25);
const LOG_FOLLOW_REFRESH_INTERVAL: Duration = Duration::from_millis(900);

fn progressive_keyboard_flags() -> KeyboardEnhancementFlags {
    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
        | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
        | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
}

fn env_flag_truthy(name: &str) -> bool {
    std::env::var(name)
        .ok()
        .map(|value| {
            matches!(
                value.trim().to_ascii_lowercase().as_str(),
                "1" | "true" | "yes" | "on"
            )
        })
        .unwrap_or(false)
}

fn terminal_program() -> Option<String> {
    std::env::var("TERM_PROGRAM").ok()
}

fn env_var_present(name: &str) -> bool {
    std::env::var_os(name).is_some()
}

fn is_remote_terminal_session() -> bool {
    env_var_present("SSH_CONNECTION")
        || env_var_present("SSH_CLIENT")
        || env_var_present("SSH_TTY")
        || env_var_present("MOSH_IP")
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalUiProfile {
    Standard,
    ReducedMotion,
    BasicCompat,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalGlyphProfile {
    Unicode,
    Ascii,
}

fn compact_spinner_frame(frame_idx: usize, glyphs: TerminalGlyphProfile) -> &'static str {
    match glyphs {
        TerminalGlyphProfile::Unicode => SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.len()],
        TerminalGlyphProfile::Ascii => ASCII_SPINNER_FRAMES[frame_idx % ASCII_SPINNER_FRAMES.len()],
    }
}

/// Estimate word count from a character count.
///
/// Uses a ~4.5-char-per-word average and buckets to the nearest 10 words to
/// prevent the status bar from flickering on every token. Returns 0 when fewer
/// than 20 chars have been written (avoids a misleading "~5 words" flash at
/// session start).
fn words_estimate(chars: u64) -> u64 {
    if chars < 20 {
        return 0;
    }
    let raw = (chars as f64 / 4.5) as u64;
    // Bucket to nearest 10 words for display stability.
    (raw / 10) * 10
}

/// Format a duration as an elapsed-time hint for the status bar.
///
/// Only returns a non-empty string once `threshold_secs` have elapsed, so the
/// hint doesn't appear on short interactions. Used by both the `Streaming` and
/// `ToolExec` status-bar arms, keeping their behaviour consistent (DRY).
fn format_elapsed_hint(elapsed: std::time::Duration, threshold_secs: u64) -> String {
    let secs = elapsed.as_secs();
    if secs >= threshold_secs {
        format!("  {}s", secs)
    } else {
        String::new()
    }
}

/// Extract the most recent markdown heading from a streaming token.
///
/// Looks for `\n# ` or `\n## ` patterns in the token text (and detects
/// headings that start at the beginning of the first token with `# `).
/// Updates `current_section` in-place when a heading is found. Heading text is
/// truncated to 30 chars for status bar width safety.
fn extract_streaming_section(token: &str, current_section: &mut Option<String>) {
    // We scan the token for newline-prefixed heading markers.
    // Handles both mid-stream cases ("\n## Title") and first-token start ("# Title").
    let candidates: &[&str] = &["\n### ", "\n## ", "\n# ", "### ", "## ", "# "];
    for marker in candidates {
        if let Some(pos) = token.find(marker) {
            let heading_start = pos + marker.len();
            // Only treat as heading if at start of token or preceded by newline.
            let valid_start =
                pos == 0 || token.as_bytes().get(pos.saturating_sub(1)).copied() == Some(b'\n');
            if !valid_start {
                continue;
            }
            let rest = &token[heading_start..];
            let heading: String = rest
                .lines()
                .next()
                .unwrap_or("")
                .trim()
                .chars()
                .take(30)
                .collect();
            if !heading.is_empty() {
                *current_section = Some(heading);
                return;
            }
        }
    }
}

fn terminal_glyph_profile_for(
    ui_profile: TerminalUiProfile,
    remote_session: bool,
    term: Option<&str>,
) -> TerminalGlyphProfile {
    if env_flag_truthy("EDGECRAB_TUI_ASCII") {
        return TerminalGlyphProfile::Ascii;
    }

    let term = term.map(str::trim).unwrap_or_default();
    if matches!(ui_profile, TerminalUiProfile::BasicCompat) || remote_session {
        return TerminalGlyphProfile::Ascii;
    }
    if matches!(term, "dumb" | "cons25") {
        return TerminalGlyphProfile::Ascii;
    }
    TerminalGlyphProfile::Unicode
}

fn adjust_ui_profile_for_remote(
    ui_profile: TerminalUiProfile,
    remote_session: bool,
) -> TerminalUiProfile {
    if remote_session && matches!(ui_profile, TerminalUiProfile::Standard) {
        TerminalUiProfile::ReducedMotion
    } else {
        ui_profile
    }
}

fn terminal_supports_direct_paging_keys(
    term_program: Option<&str>,
    term: Option<&str>,
    ui_profile: TerminalUiProfile,
    remote_session: bool,
) -> bool {
    if env_flag_truthy("EDGECRAB_TUI_NO_DIRECT_PAGING") {
        return false;
    }
    if env_flag_truthy("EDGECRAB_TUI_DIRECT_PAGING") {
        return true;
    }
    if remote_session || matches!(ui_profile, TerminalUiProfile::BasicCompat) {
        return false;
    }
    if env_var_present("TMUX") || env_var_present("STY") {
        return false;
    }

    let term_program = term_program
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let term = term.map(str::trim).filter(|value| !value.is_empty());

    match term_program {
        Some("Apple_Terminal") => false,
        Some(
            "iTerm.app" | "WezTerm" | "vscode" | "WarpTerminal" | "ghostty" | "rio" | "Tabby"
            | "Hyper" | "HyperTerm" | "contour" | "JetBrains-JediTerm",
        ) => true,
        Some(_) => true,
        None => term
            .map(|value| value.to_ascii_lowercase())
            .map(|value| {
                !matches!(
                    value.as_str(),
                    "dumb" | "linux" | "vt100" | "vt220" | "ansi"
                ) && !value.starts_with("screen")
                    && !value.starts_with("tmux")
                    && !value.starts_with("rxvt")
                    && !value.starts_with("putty")
            })
            .unwrap_or(false),
    }
}

fn terminal_ui_profile_for(
    term_program: Option<&str>,
    term: Option<&str>,
    forced_basic: bool,
    forced_reduced_motion: bool,
    forced_fast: bool,
) -> TerminalUiProfile {
    let term_program = term_program
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let term = term.map(str::trim).filter(|value| !value.is_empty());

    if forced_fast {
        return TerminalUiProfile::Standard;
    }
    if forced_basic {
        return TerminalUiProfile::BasicCompat;
    }
    if forced_reduced_motion {
        return TerminalUiProfile::ReducedMotion;
    }

    match term_program {
        Some("Apple_Terminal") => TerminalUiProfile::BasicCompat,
        Some(
            "gnome-terminal"
            | "gnome-terminal-server"
            | "xfce4-terminal"
            | "konsole"
            | "mate-terminal"
            | "tilix"
            | "terminator"
            | "qterminal"
            | "guake"
            | "yakuake"
            | "lxterminal"
            | "ptyxis"
            | "deepin-terminal",
        ) => TerminalUiProfile::ReducedMotion,
        Some(
            "iTerm.app" | "WezTerm" | "vscode" | "WarpTerminal" | "ghostty" | "rio" | "Tabby"
            | "Hyper" | "HyperTerm" | "contour" | "JetBrains-JediTerm",
        ) => TerminalUiProfile::Standard,
        _ => {
            if let Some(term_name) = term.map(|value| value.to_ascii_lowercase())
                && (matches!(term_name.as_str(), "linux" | "vt100" | "vt220" | "ansi")
                    || term_name.starts_with("rxvt")
                    || term_name.starts_with("screen")
                    || term_name.starts_with("tmux")
                    || term_name.starts_with("eterm")
                    || term_name.starts_with("putty"))
            {
                return TerminalUiProfile::ReducedMotion;
            }
            TerminalUiProfile::Standard
        }
    }
}

fn detect_terminal_ui_profile() -> TerminalUiProfile {
    let forced_basic = env_flag_truthy("EDGECRAB_TUI_COMPAT");
    let forced_reduced_motion = env_flag_truthy("EDGECRAB_TUI_REDUCED_MOTION");
    let forced_fast = env_flag_truthy("EDGECRAB_TUI_FAST");
    if forced_fast || forced_basic || forced_reduced_motion {
        return terminal_ui_profile_for(
            terminal_program().as_deref(),
            std::env::var("TERM").ok().as_deref(),
            forced_basic,
            forced_reduced_motion,
            forced_fast,
        );
    }

    // Termux on Android: compact mode (BasicCompat) for narrow screens.
    if *edgecrab_types::IS_TERMUX {
        return TerminalUiProfile::BasicCompat;
    }

    // Narrow terminals (< 60 cols): fall back to compact mode to avoid
    // layout overflow on small screens.
    if let Ok((cols, _)) = crossterm::terminal::size()
        && cols < 60
    {
        return TerminalUiProfile::BasicCompat;
    }

    // Fast-path capability markers from terminal envs. These are more reliable
    // than TERM=xterm-256color when TERM_PROGRAM is absent.
    if env_var_present("KITTY_WINDOW_ID")
        || env_var_present("WEZTERM_PANE")
        || env_var_present("WT_SESSION")
        || env_var_present("ALACRITTY_SOCKET")
        || env_var_present("GHOSTTY_RESOURCES_DIR")
    {
        return TerminalUiProfile::Standard;
    }

    // Linux desktop / multiplexer markers that tend to benefit from fewer
    // redraws even though they still support the richer input path.
    if env_var_present("VTE_VERSION")
        || env_var_present("KONSOLE_VERSION")
        || env_var_present("GNOME_TERMINAL_SCREEN")
        || env_var_present("TMUX")
    {
        return TerminalUiProfile::ReducedMotion;
    }

    terminal_ui_profile_for(
        terminal_program().as_deref(),
        std::env::var("TERM").ok().as_deref(),
        false,
        false,
        false,
    )
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TerminalRuntimeCapabilities {
    ui_profile: TerminalUiProfile,
    glyph_profile: TerminalGlyphProfile,
    remote_session: bool,
    direct_paging_keys_supported: bool,
    compact_status_bar: bool,
    rich_transcript: bool,
    show_output_scrollbar: bool,
    show_ghost_hint: bool,
    min_draw_interval: Duration,
    animate_status_indicators: bool,
    mouse_capture_enabled: bool,
    live_token_display_enabled: bool,
}

fn terminal_runtime_capabilities() -> TerminalRuntimeCapabilities {
    let remote_session = is_remote_terminal_session();
    let term = std::env::var("TERM").ok();
    let term_program = terminal_program();
    let ui_profile = adjust_ui_profile_for_remote(detect_terminal_ui_profile(), remote_session);

    let glyph_profile = terminal_glyph_profile_for(ui_profile, remote_session, term.as_deref());
    let direct_paging_keys_supported = terminal_supports_direct_paging_keys(
        term_program.as_deref(),
        term.as_deref(),
        ui_profile,
        remote_session,
    );
    let compact_status_bar = remote_session
        || matches!(
            ui_profile,
            TerminalUiProfile::ReducedMotion | TerminalUiProfile::BasicCompat
        );
    let rich_transcript = matches!(ui_profile, TerminalUiProfile::Standard) && !remote_session;
    let show_output_scrollbar =
        !matches!(ui_profile, TerminalUiProfile::BasicCompat) && !remote_session;
    let show_ghost_hint = !matches!(ui_profile, TerminalUiProfile::BasicCompat);
    let min_draw_interval = match ui_profile {
        TerminalUiProfile::Standard => Duration::ZERO,
        TerminalUiProfile::ReducedMotion => Duration::from_millis(40),
        TerminalUiProfile::BasicCompat => Duration::from_millis(90),
    };
    let animate_status_indicators = matches!(ui_profile, TerminalUiProfile::Standard);
    let mouse_capture_enabled = !matches!(ui_profile, TerminalUiProfile::BasicCompat);
    let live_token_display_enabled = !matches!(ui_profile, TerminalUiProfile::BasicCompat);

    TerminalRuntimeCapabilities {
        ui_profile,
        glyph_profile,
        remote_session,
        direct_paging_keys_supported,
        compact_status_bar,
        rich_transcript,
        show_output_scrollbar,
        show_ghost_hint,
        min_draw_interval,
        animate_status_indicators,
        mouse_capture_enabled,
        live_token_display_enabled,
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct RecoveredAssistantTurn {
    reasoning: Option<String>,
    text: String,
}

fn recover_latest_assistant_turn(
    messages: &[edgecrab_types::Message],
) -> Option<RecoveredAssistantTurn> {
    messages.iter().rev().find_map(|message| {
        if message.role != edgecrab_types::Role::Assistant {
            return None;
        }

        let text = message.text_content();
        let reasoning = message
            .reasoning
            .clone()
            .filter(|text| !text.trim().is_empty());
        if text.trim().is_empty() && reasoning.is_none() {
            return None;
        }

        Some(RecoveredAssistantTurn { reasoning, text })
    })
}

async fn forward_stream_event_to_tui(
    event: edgecrab_core::agent::StreamEvent,
    tx: &mpsc::UnboundedSender<AgentResponse>,
    hook_registry: &edgecrab_gateway::hooks::HookRegistry,
    saw_token_event: &mut bool,
    saw_reasoning_event: &mut bool,
    saw_terminal_event: &mut bool,
) -> bool {
    use edgecrab_core::agent::StreamEvent;

    match event {
        StreamEvent::Token(text) => {
            *saw_token_event = true;
            tracing::info!(len = text.len(), "TUI→agent: forwarding token");
            let _ = tx.send(AgentResponse::Token(text));
        }
        StreamEvent::Reasoning(text) => {
            *saw_reasoning_event = true;
            tracing::info!(len = text.len(), "TUI→agent: forwarding reasoning");
            let _ = tx.send(AgentResponse::Reasoning(text));
        }
        StreamEvent::ToolExec {
            tool_call_id,
            name,
            args_json,
        } => {
            tracing::info!(tool = %name, "TUI→agent: forwarding tool exec");
            let _ = tx.send(AgentResponse::ToolExec {
                tool_call_id,
                name,
                args_json,
            });
        }
        StreamEvent::ToolProgress {
            tool_call_id,
            name,
            message,
        } => {
            tracing::info!(tool = %name, "TUI→agent: forwarding tool progress");
            let _ = tx.send(AgentResponse::ToolProgress {
                tool_call_id,
                name,
                message,
            });
        }
        StreamEvent::ToolDone {
            tool_call_id,
            name,
            args_json,
            result_preview,
            duration_ms,
            is_error,
        } => {
            tracing::info!(tool = %name, is_error, "TUI→agent: forwarding tool done");
            let _ = tx.send(AgentResponse::ToolDone {
                tool_call_id,
                name,
                args_json,
                result_preview,
                duration_ms,
                is_error,
            });
        }
        StreamEvent::SubAgentStart {
            task_index,
            task_count,
            goal,
        } => {
            tracing::info!(
                task_index,
                task_count,
                "TUI→agent: forwarding subagent start"
            );
            let _ = tx.send(AgentResponse::SubAgentStart {
                task_index,
                task_count,
                goal,
            });
        }
        StreamEvent::SubAgentReasoning {
            task_index,
            task_count,
            text,
        } => {
            tracing::info!(
                task_index,
                task_count,
                len = text.len(),
                "TUI→agent: forwarding subagent reasoning"
            );
            let _ = tx.send(AgentResponse::SubAgentReasoning {
                task_index,
                task_count,
                text,
            });
        }
        StreamEvent::SubAgentToolExec {
            task_index,
            task_count,
            name,
            args_json,
        } => {
            tracing::info!(
                task_index,
                task_count,
                tool = %name,
                "TUI→agent: forwarding subagent tool exec"
            );
            let _ = tx.send(AgentResponse::SubAgentToolExec {
                task_index,
                task_count,
                name,
                args_json,
            });
        }
        StreamEvent::SubAgentFinish {
            task_index,
            task_count,
            status,
            duration_ms,
            summary,
            api_calls,
            model,
        } => {
            tracing::info!(
                task_index,
                task_count,
                status = %status,
                "TUI→agent: forwarding subagent finish"
            );
            let _ = tx.send(AgentResponse::SubAgentFinish {
                task_index,
                task_count,
                status,
                duration_ms,
                summary,
                api_calls,
                model,
            });
        }
        StreamEvent::RunFinished { outcome } => {
            tracing::info!(
                state = outcome.state.as_str(),
                exit_reason = outcome.exit_reason.as_str(),
                "TUI→agent: forwarding run outcome"
            );
            let _ = tx.send(AgentResponse::RunFinished { outcome });
        }
        StreamEvent::Done => {
            *saw_terminal_event = true;
            tracing::info!("TUI→agent: forwarding done");
            let _ = tx.send(AgentResponse::Done);
            return true;
        }
        StreamEvent::Error(error) => {
            *saw_terminal_event = true;
            tracing::warn!(%error, "TUI→agent: forwarding error");
            let _ = tx.send(AgentResponse::Error(error));
            return true;
        }
        StreamEvent::Clarify {
            question,
            choices,
            response_tx,
        } => {
            tracing::info!("TUI→agent: forwarding clarify request");
            let _ = tx.send(AgentResponse::Clarify {
                question,
                choices,
                response_tx,
            });
        }
        StreamEvent::Approval {
            command,
            full_command,
            reasons: _,
            response_tx,
        } => {
            tracing::info!("TUI→agent: forwarding approval request");
            let _ = tx.send(AgentResponse::Approval {
                command,
                full_command,
                response_tx,
            });
        }
        StreamEvent::SecretRequest {
            var_name,
            prompt,
            is_sudo,
            response_tx,
        } => {
            tracing::info!(var = %var_name, "TUI→agent: forwarding secret request");
            let _ = tx.send(AgentResponse::SecretRequest {
                var_name,
                prompt,
                is_sudo,
                response_tx,
            });
        }
        StreamEvent::HookEvent {
            event,
            context_json,
        } => {
            tracing::info!(hook = %event, "TUI→agent: handling hook event");
            if let Ok(ctx) =
                serde_json::from_str::<edgecrab_gateway::hooks::HookContext>(&context_json)
            {
                hook_registry.emit(&event, &ctx).await;
            } else {
                tracing::warn!(hook = %event, "TUI→agent: hook context parse failed");
            }
        }
        StreamEvent::ContextPressure {
            estimated_tokens,
            threshold_tokens,
        } => {
            tracing::info!(
                estimated_tokens,
                threshold_tokens,
                "TUI→agent: forwarding context pressure notice"
            );
            let _ = tx.send(AgentResponse::Notice(format_context_pressure_notice(
                estimated_tokens,
                threshold_tokens,
            )));
        }

        // Steering events — update the TUI's pending steer counter and status bar.
        // These events originate from the agent loop (conversation.rs) and are
        // informational; no AgentResponse forwarding is needed.
        StreamEvent::SteerPending { count } => {
            tracing::debug!(count, "TUI←agent: steer pending notification");
            // Pending count is managed optimistically in the TUI already —
            // this event can be used to synchronise if needed in the future.
        }

        StreamEvent::SteerApplied { message } => {
            tracing::info!(
                len = message.len(),
                "TUI←agent: steer applied — agent received new guidance"
            );
            let _ = tx.send(AgentResponse::Notice(format!(
                "⛵ Steering applied ({})",
                message.chars().take(72).collect::<String>()
            )));
        }
    }

    false
}

async fn forward_agent_stream_to_tui(
    agent: Arc<Agent>,
    mut chunk_rx: mpsc::UnboundedReceiver<edgecrab_core::agent::StreamEvent>,
    mut agent_task: tokio::task::JoinHandle<Result<(), edgecrab_types::AgentError>>,
    tx: mpsc::UnboundedSender<AgentResponse>,
    hook_registry: Arc<edgecrab_gateway::hooks::HookRegistry>,
) {
    let mut saw_terminal_event = false;
    let mut saw_token_event = false;
    let mut saw_reasoning_event = false;
    let mut agent_result: Option<
        Result<Result<(), edgecrab_types::AgentError>, tokio::task::JoinError>,
    > = None;

    loop {
        if let Some(join_result) = agent_result.take() {
            while let Ok(event) = chunk_rx.try_recv() {
                if forward_stream_event_to_tui(
                    event,
                    &tx,
                    &hook_registry,
                    &mut saw_token_event,
                    &mut saw_reasoning_event,
                    &mut saw_terminal_event,
                )
                .await
                {
                    return;
                }
            }

            tracing::info!(
                saw_terminal_event,
                saw_token_event,
                saw_reasoning_event,
                ?join_result,
                "TUI→agent: stream pump observed inner task completion"
            );

            if saw_terminal_event {
                return;
            }

            match join_result {
                Ok(Ok(())) => {
                    let messages = agent.messages().await;
                    if let Some(turn) = recover_latest_assistant_turn(&messages) {
                        tracing::warn!(
                            recovered_text_len = turn.text.len(),
                            recovered_reasoning = turn.reasoning.is_some(),
                            "TUI→agent: recovered assistant reply after missing terminal stream event"
                        );
                        if !saw_reasoning_event && let Some(reasoning) = turn.reasoning {
                            let _ = tx.send(AgentResponse::Reasoning(reasoning));
                        }
                        if !saw_token_event && !turn.text.is_empty() {
                            let _ = tx.send(AgentResponse::Token(turn.text));
                        }
                        let _ = tx.send(AgentResponse::Done);
                    } else {
                        let _ = tx.send(AgentResponse::Error(
                            "Agent completed, but no assistant reply could be recovered for the TUI."
                                .to_string(),
                        ));
                    }
                }
                Ok(Err(err)) => {
                    let _ = tx.send(AgentResponse::Error(err.to_string()));
                }
                Err(err) => {
                    let _ = tx.send(AgentResponse::Error(format!("Agent task failed: {err}")));
                }
            }
            return;
        }

        tokio::select! {
            join_result = &mut agent_task => {
                agent_result = Some(join_result);
            }
            event = chunk_rx.recv() => {
                match event {
                    Some(event) => {
                        if forward_stream_event_to_tui(
                            event,
                            &tx,
                            &hook_registry,
                            &mut saw_token_event,
                            &mut saw_reasoning_event,
                            &mut saw_terminal_event,
                        )
                        .await
                        {
                            return;
                        }
                    }
                    None => {
                        tracing::warn!(
                            saw_terminal_event,
                            saw_token_event,
                            saw_reasoning_event,
                            "TUI→agent: stream channel closed before terminal event"
                        );
                        if saw_terminal_event {
                            return;
                        }
                        agent_result = Some((&mut agent_task).await);
                    }
                }
            }
        }
    }
}

fn normalize_terminal_control_key(mut key: event::KeyEvent) -> event::KeyEvent {
    if key.modifiers.is_empty()
        && let KeyCode::Char(ch) = key.code
    {
        match ch {
            '\u{2}' => {
                key.code = KeyCode::Char('b');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{3}' => {
                key.code = KeyCode::Char('c');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{4}' => {
                key.code = KeyCode::Char('d');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{6}' => {
                key.code = KeyCode::Char('f');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{7}' => {
                key.code = KeyCode::Char('g');
                key.modifiers = KeyModifiers::CONTROL;
            }
            // In basic PTYs a bare LF/CR is usually just Enter. Treating it
            // as Ctrl+J makes Terminal.app open compose mode instead of
            // submitting, which can strand the user before the agent runs.
            '\n' | '\r' => {
                key.code = KeyCode::Enter;
                key.modifiers = KeyModifiers::NONE;
            }
            '\u{c}' => {
                key.code = KeyCode::Char('l');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{f}' => {
                key.code = KeyCode::Char('o');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{13}' => {
                key.code = KeyCode::Char('s');
                key.modifiers = KeyModifiers::CONTROL;
            }
            '\u{15}' => {
                key.code = KeyCode::Char('u');
                key.modifiers = KeyModifiers::CONTROL;
            }
            _ => {}
        }
    }
    key
}

fn normalize_fallback_paging_key(mut key: event::KeyEvent) -> event::KeyEvent {
    if key.modifiers == KeyModifiers::CONTROL {
        match key.code {
            KeyCode::Char('b') | KeyCode::Char('B') => {
                key.code = KeyCode::PageUp;
                key.modifiers = KeyModifiers::NONE;
            }
            KeyCode::Char('f') | KeyCode::Char('F') => {
                key.code = KeyCode::PageDown;
                key.modifiers = KeyModifiers::NONE;
            }
            _ => {}
        }
    }
    key
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PagingIntent {
    Up,
    Down,
}

fn paging_intent_for_key(key: event::KeyEvent) -> Option<PagingIntent> {
    let blocked_modifiers = KeyModifiers::ALT | KeyModifiers::SUPER;
    match (key.modifiers, key.code) {
        (_, KeyCode::PageUp) => Some(PagingIntent::Up),
        (_, KeyCode::PageDown) => Some(PagingIntent::Down),
        (mods, KeyCode::Char('\u{2}')) if !mods.intersects(blocked_modifiers) => {
            Some(PagingIntent::Up)
        }
        (mods, KeyCode::Char('\u{6}')) if !mods.intersects(blocked_modifiers) => {
            Some(PagingIntent::Down)
        }
        (mods, KeyCode::Char('b' | 'B'))
            if mods.contains(KeyModifiers::CONTROL) && !mods.intersects(blocked_modifiers) =>
        {
            Some(PagingIntent::Up)
        }
        (mods, KeyCode::Char('f' | 'F'))
            if mods.contains(KeyModifiers::CONTROL) && !mods.intersects(blocked_modifiers) =>
        {
            Some(PagingIntent::Down)
        }
        _ => None,
    }
}

fn selector_search_char(key: &event::KeyEvent) -> Option<char> {
    if !key.modifiers.is_empty() {
        return None;
    }
    match key.code {
        KeyCode::Char(c) => Some(c),
        _ => None,
    }
}

fn selector_action_key(key: &event::KeyEvent, ch: char) -> bool {
    let upper = ch.to_ascii_uppercase();
    match key.code {
        KeyCode::Char(c) => c == upper,
        _ => false,
    }
}

fn log_level_shortcut(key: &event::KeyEvent) -> Option<LogLevelSetting> {
    if !key.modifiers.is_empty() {
        return None;
    }
    match key.code {
        KeyCode::Char('1') => Some(LogLevelSetting::Error),
        KeyCode::Char('2') => Some(LogLevelSetting::Warn),
        KeyCode::Char('3') => Some(LogLevelSetting::Info),
        KeyCode::Char('4') => Some(LogLevelSetting::Debug),
        KeyCode::Char('5') => Some(LogLevelSetting::Trace),
        _ => None,
    }
}

fn selector_marker(is_selected: bool, accent: Color, bg: Option<Color>) -> Span<'static> {
    let mut style = Style::default().fg(if is_selected { accent } else { Color::DarkGray });
    if let Some(bg) = bg {
        style = style.bg(bg);
    }
    if is_selected {
        style = style.add_modifier(Modifier::BOLD);
    }
    Span::styled(if is_selected { "▶ " } else { "  " }, style)
}

const SESSION_BROWSER_LIMIT: usize = 250;
const SESSION_BROWSER_SEARCH_LIMIT: usize = 120;
const STOP_HOOK_AUTO_PREFIX: &str = "[edgecrab:stop-hook] ";

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
enum SplitPaneFocus {
    #[default]
    List,
    Detail,
}

impl SplitPaneFocus {
    fn toggle(self) -> Self {
        match self {
            Self::List => Self::Detail,
            Self::Detail => Self::List,
        }
    }
}

#[derive(Clone, Copy, Debug)]
struct DetailPaneState {
    focus: SplitPaneFocus,
    scroll: u16,
}

impl Default for DetailPaneState {
    fn default() -> Self {
        Self {
            focus: SplitPaneFocus::List,
            scroll: 0,
        }
    }
}

impl DetailPaneState {
    fn reset(&mut self) {
        *self = Self::default();
    }

    fn reset_scroll(&mut self) {
        self.scroll = 0;
    }

    fn page_up(&mut self, step: u16) {
        self.scroll = self.scroll.saturating_sub(step.max(1));
    }

    fn page_down(&mut self, step: u16) {
        self.scroll = self.scroll.saturating_add(step.max(1));
    }
}

fn format_context_pressure_notice(estimated_tokens: usize, threshold_tokens: usize) -> String {
    let ratio = if threshold_tokens == 0 {
        0.0
    } else {
        (estimated_tokens as f32 / threshold_tokens as f32).clamp(0.0, 1.0)
    };
    let percent = (ratio * 100.0).round() as usize;
    let width = 16usize;
    let filled = ((ratio * width as f32).round() as usize).min(width);
    let bar = format!("{}{}", "▰".repeat(filled), "▱".repeat(width - filled));
    format!(
        "⚠ Context {bar} {percent}% to compression ({estimated_tokens}/{threshold_tokens} tokens)"
    )
}

fn format_run_outcome_notice(outcome: &edgecrab_types::RunOutcome) -> String {
    let headline = format!("{} {}", outcome.state.emoji(), outcome.state.headline());

    let mut lines = vec![headline.clone()];
    let summary = outcome.user_summary.trim();
    if !summary.is_empty() && summary != headline && summary != outcome.state.headline() {
        lines.push(summary.to_string());
    }
    if let Some(hint) = outcome.state.operator_hint() {
        lines.push(hint.to_string());
    }
    if outcome.active_tasks > 0 || outcome.blocked_tasks > 0 {
        lines.push(format!(
            "Tasks remaining: {} active, {} blocked.",
            outcome.active_tasks, outcome.blocked_tasks
        ));
    }
    if let Some(evidence) = outcome.evidence.iter().find(|item| !item.trim().is_empty()) {
        lines.push(format!(
            "Evidence: {}",
            edgecrab_core::safe_truncate(evidence, 120)
        ));
    }
    lines.join("\n")
}

fn run_outcome_badge_style(outcome: &edgecrab_types::RunOutcome) -> Style {
    match outcome.state {
        edgecrab_types::CompletionDecision::Completed => Style::default()
            .fg(Color::Rgb(12, 28, 20))
            .bg(Color::Rgb(108, 220, 155))
            .add_modifier(Modifier::BOLD),
        edgecrab_types::CompletionDecision::NeedsUserInput
        | edgecrab_types::CompletionDecision::Blocked
        | edgecrab_types::CompletionDecision::NeedsVerification => Style::default()
            .fg(Color::Rgb(36, 24, 10))
            .bg(Color::Rgb(255, 204, 92))
            .add_modifier(Modifier::BOLD),
        edgecrab_types::CompletionDecision::BudgetExhausted
        | edgecrab_types::CompletionDecision::Incomplete => Style::default()
            .fg(Color::Rgb(40, 22, 8))
            .bg(Color::Rgb(255, 170, 90))
            .add_modifier(Modifier::BOLD),
        edgecrab_types::CompletionDecision::Interrupted
        | edgecrab_types::CompletionDecision::Failed => Style::default()
            .fg(Color::Rgb(38, 12, 12))
            .bg(Color::Rgb(255, 120, 120))
            .add_modifier(Modifier::BOLD),
    }
}

fn format_task_status_progress_notice(preview: &str) -> String {
    let trimmed = preview.trim();
    let lower = trimmed.to_ascii_lowercase();

    if let Some(rest) = trimmed.split_once(':').map(|(_, rest)| rest.trim()) {
        if lower.starts_with("completed:") {
            return format!("✅ Milestone reached — {rest}");
        }
        if lower.starts_with("blocked:") {
            return format!("⏸ Progress blocked — {rest}");
        }
        if lower.starts_with("progress:") {
            return format!("📍 Progress update — {rest}");
        }
    }

    format!("📍 Progress update — {trimmed}")
}

fn run_outcome_role(outcome: &edgecrab_types::RunOutcome) -> OutputRole {
    match outcome.state {
        edgecrab_types::CompletionDecision::Completed => OutputRole::System,
        edgecrab_types::CompletionDecision::NeedsUserInput
        | edgecrab_types::CompletionDecision::Blocked
        | edgecrab_types::CompletionDecision::Incomplete
        | edgecrab_types::CompletionDecision::NeedsVerification => OutputRole::System,
        edgecrab_types::CompletionDecision::BudgetExhausted
        | edgecrab_types::CompletionDecision::Interrupted
        | edgecrab_types::CompletionDecision::Failed => OutputRole::Error,
    }
}

fn stop_hook_event_name(outcome: &edgecrab_types::RunOutcome) -> &'static str {
    match outcome.state {
        edgecrab_types::CompletionDecision::Completed => "agent:task_completed",
        edgecrab_types::CompletionDecision::NeedsUserInput => "agent:needs_input",
        edgecrab_types::CompletionDecision::Blocked => "agent:task_blocked",
        edgecrab_types::CompletionDecision::NeedsVerification => "agent:needs_verification",
        edgecrab_types::CompletionDecision::BudgetExhausted => "agent:budget_exhausted",
        edgecrab_types::CompletionDecision::Interrupted => "agent:interrupted",
        edgecrab_types::CompletionDecision::Failed => "agent:failed",
        edgecrab_types::CompletionDecision::Incomplete => "agent:task_incomplete",
    }
}

fn build_run_outcome_hook_context(
    event: &str,
    outcome: &edgecrab_types::RunOutcome,
) -> edgecrab_gateway::hooks::HookContext {
    edgecrab_gateway::hooks::HookContext::new(event)
        .with_platform("cli")
        .with_str("completion_state", outcome.state.as_str())
        .with_str("exit_reason", outcome.exit_reason.as_str())
        .with_str("summary", outcome.user_summary.clone())
        .with_value("active_tasks", outcome.active_tasks as u64)
        .with_value("blocked_tasks", outcome.blocked_tasks as u64)
        .with_value("evidence_count", outcome.evidence.len() as u64)
}

fn build_stop_hook_followup_prompt(reason: &str) -> String {
    let reason = reason.trim();
    let detail = if reason.is_empty() {
        "a local stop hook requires one more pass before completion can be accepted"
    } else {
        reason
    };
    format!(
        "{STOP_HOOK_AUTO_PREFIX}Continue working. A local stop hook blocked completion because {detail}. Do not claim the task is finished yet. Either complete the remaining work with evidence or report the blocker clearly using the task-status tools."
    )
}

fn hook_home_display_path() -> String {
    let home = std::env::var("EDGECRAB_HOME").unwrap_or_else(|_| "~/.edgecrab".to_string());
    format!("{home}/hooks")
}

fn format_hook_status_line(hooks: &[edgecrab_gateway::hooks::LoadedHookInfo]) -> String {
    match hooks.len() {
        0 => format!("Hooks: none loaded from {}", hook_home_display_path()),
        1 => format!(
            "Hooks: 1 file-based hook loaded from {}",
            hook_home_display_path()
        ),
        n => format!(
            "Hooks: {n} file-based hooks loaded from {}",
            hook_home_display_path()
        ),
    }
}

fn format_hook_help_body() -> String {
    format!(
        "Inspect and manage the local lifecycle hook registry.\n\nCommands:\n• /hooks           open the hook inspector\n• /hooks list      open the hook inspector\n• /hooks status    print a compact status summary\n• /hooks reload    reload HOOK.yaml and handler scripts\n• /hooks help      show this guide\n\nDirectory:\n{}\n\nUseful lifecycle events:\n• agent:start\n• agent:stop\n• agent:task_completed\n• agent:task_blocked\n• agent:needs_input\n• agent:needs_verification\n• command:*\n• tool:pre\n• tool:post",
        hook_home_display_path()
    )
}

fn format_loaded_hooks_report(hooks: &[edgecrab_gateway::hooks::LoadedHookInfo]) -> String {
    let hooks_dir = hook_home_display_path();

    if hooks.is_empty() {
        return format!(
            "No file-based hooks are currently loaded.\n\nHooks directory: {hooks_dir}\n\nTo add one, create a folder with HOOK.yaml and handler.py / handler.ts / handler.js, then run /hooks reload.\n\nUseful events:\n• agent:stop\n• agent:task_completed\n• agent:task_blocked\n• agent:needs_input\n• command:*\n• tool:pre / tool:post"
        );
    }

    let mut lines = vec![
        format!("Loaded hooks: {}", hooks.len()),
        format!("Hooks directory: {hooks_dir}"),
        String::new(),
    ];

    for hook in hooks {
        let description = hook.description.trim();
        let desc = if description.is_empty() {
            "No description provided."
        } else {
            description
        };
        let events = if hook.events.is_empty() {
            "(none)".to_string()
        } else {
            hook.events.join(", ")
        };
        lines.push(format!(
            "• {} [{} | prio {} | {}s]\n  {}\n  events: {}\n  path: {}",
            hook.name,
            hook.language,
            hook.priority,
            hook.timeout_secs,
            desc,
            events,
            hook.path.display()
        ));
    }

    lines.push(String::new());
    lines.push("Tip: use /hooks reload after editing a HOOK.yaml or handler script.".into());
    lines.join("\n")
}

fn context_usage_ratio(tokens: u64, context_window: Option<u64>) -> Option<f64> {
    context_window
        .filter(|&cw| cw > 0)
        .map(|cw| (tokens as f64 / cw as f64).clamp(0.0, 1.0))
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct AudioPlayerSpec {
    program: &'static str,
    args: &'static [&'static str],
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioRecorderBackend {
    FfmpegAvFoundation,
    FfmpegDShow,
    Arecord,
    Rec,
    FfmpegPulse,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VoiceRecordingStopMode {
    Manual,
    AutoSilence,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VoiceRecordingFinishReason {
    ManualStop,
    AutoSilence,
    RecorderExited,
}

#[derive(Clone, Copy, Debug, PartialEq)]
struct VoiceRecordingProfile {
    stop_mode: VoiceRecordingStopMode,
    silence_threshold_db: f32,
    silence_duration_ms: u32,
}

#[derive(Clone, Copy, Debug, PartialEq)]
struct VoiceTranscriptMeta {
    capture_duration_secs: f64,
    continuous_session: bool,
}

const AUDIO_PLAYER_CANDIDATES: &[AudioPlayerSpec] = &[
    AudioPlayerSpec {
        program: "afplay",
        args: &[],
    },
    AudioPlayerSpec {
        program: "ffplay",
        args: &["-nodisp", "-autoexit", "-loglevel", "error"],
    },
    AudioPlayerSpec {
        program: "mpv",
        args: &["--no-terminal", "--really-quiet"],
    },
    AudioPlayerSpec {
        program: "paplay",
        args: &[],
    },
    AudioPlayerSpec {
        program: "aplay",
        args: &[],
    },
    AudioPlayerSpec {
        program: "play",
        args: &["-q"],
    },
];

fn describe_audio_recorder(backend: AudioRecorderBackend) -> &'static str {
    match backend {
        AudioRecorderBackend::FfmpegAvFoundation => "ffmpeg-avfoundation",
        AudioRecorderBackend::FfmpegDShow => "ffmpeg-dshow",
        AudioRecorderBackend::Arecord => "arecord",
        AudioRecorderBackend::Rec => "rec",
        AudioRecorderBackend::FfmpegPulse => "ffmpeg-pulse",
    }
}

fn recorder_supports_auto_silence_stop(backend: AudioRecorderBackend) -> bool {
    matches!(
        backend,
        AudioRecorderBackend::FfmpegAvFoundation
            | AudioRecorderBackend::FfmpegPulse
            | AudioRecorderBackend::Rec
    )
}

fn recorder_auto_silence_support_message(backend: AudioRecorderBackend) -> &'static str {
    match backend {
        AudioRecorderBackend::FfmpegAvFoundation => "supported via ffmpeg silencedetect",
        AudioRecorderBackend::FfmpegPulse => "supported via ffmpeg silencedetect",
        AudioRecorderBackend::Rec => "supported via SoX silence effect",
        AudioRecorderBackend::Arecord => {
            "not supported by arecord alone; install `rec` or `ffmpeg` for hands-free continuous capture"
        }
        AudioRecorderBackend::FfmpegDShow => {
            "not supported reliably on Windows ffmpeg dshow; use push-to-talk/manual stop"
        }
    }
}

fn format_silencedetect_threshold(threshold_db: f32) -> String {
    let value = if threshold_db.is_finite() {
        threshold_db
    } else {
        -40.0
    };
    format!("{value:.1}dB")
}

fn parse_ffmpeg_silence_start(line: &str) -> Option<f64> {
    let idx = line.find("silence_start:")?;
    let value = line[(idx + "silence_start:".len())..].trim();
    value.parse::<f64>().ok()
}

fn line_mentions_ffmpeg_silence_end(line: &str) -> bool {
    line.contains("silence_end:")
}

fn should_stop_ffmpeg_on_silence(
    line: &str,
    heard_speech: bool,
    min_post_speech_secs: f64,
) -> bool {
    let Some(silence_start) = parse_ffmpeg_silence_start(line) else {
        return false;
    };
    heard_speech || silence_start >= min_post_speech_secs
}

#[cfg(unix)]
fn interrupt_process_id(pid: u32) -> bool {
    if let Ok(pid) = i32::try_from(pid) {
        // SIGINT lets ffmpeg/arecord finalize WAV headers before exit.
        unsafe {
            libc::kill(pid, libc::SIGINT);
        }
        return true;
    }
    false
}

#[cfg(not(unix))]
fn interrupt_process_id(_pid: u32) -> bool {
    false
}

fn spawn_ffmpeg_silence_monitor(pid: u32, stderr: std::process::ChildStderr) {
    std::thread::spawn(move || {
        let reader = io::BufReader::new(stderr);
        let mut heard_speech = false;
        for line in reader.lines() {
            let Ok(line) = line else {
                break;
            };
            if line_mentions_ffmpeg_silence_end(&line) {
                heard_speech = true;
                continue;
            }
            if should_stop_ffmpeg_on_silence(&line, heard_speech, 0.35) {
                let _ = interrupt_process_id(pid);
                break;
            }
        }
    });
}

fn select_audio_player_with<F>(mut exists: F) -> Option<AudioPlayerSpec>
where
    F: FnMut(&str) -> bool,
{
    AUDIO_PLAYER_CANDIDATES
        .iter()
        .copied()
        .find(|candidate| exists(candidate.program))
}

fn preferred_audio_player() -> Option<AudioPlayerSpec> {
    select_audio_player_with(|program| which::which(program).is_ok())
}

fn select_audio_recorder_with<F>(mut exists: F) -> Option<AudioRecorderBackend>
where
    F: FnMut(&str) -> bool,
{
    if cfg!(target_os = "macos") && exists("ffmpeg") {
        return Some(AudioRecorderBackend::FfmpegAvFoundation);
    }
    if cfg!(target_os = "windows") && exists("ffmpeg") {
        return Some(AudioRecorderBackend::FfmpegDShow);
    }
    if exists("arecord") {
        return Some(AudioRecorderBackend::Arecord);
    }
    if exists("rec") {
        return Some(AudioRecorderBackend::Rec);
    }
    if exists("ffmpeg") {
        return Some(AudioRecorderBackend::FfmpegPulse);
    }
    None
}

fn preferred_audio_recorder() -> Option<AudioRecorderBackend> {
    select_audio_recorder_with(|program| which::which(program).is_ok())
}

fn voice_recording_ready(
    input_device: Option<&str>,
    stop_mode: VoiceRecordingStopMode,
) -> Result<AudioRecorderBackend, String> {
    if !TranscribeAudioTool.is_available() {
        return Err(
            "No speech-to-text backend available. Install local `whisper`, or configure `GROQ_API_KEY` / `OPENAI_API_KEY`."
                .into(),
        );
    }

    let backend = preferred_audio_recorder().ok_or_else(|| {
        String::from(
            "No supported microphone recorder found. Install one of: `ffmpeg`, `arecord`, or `rec`.",
        )
    })?;
    if matches!(backend, AudioRecorderBackend::FfmpegDShow) && input_device.is_none() {
        return Err(
            "Windows microphone capture requires `voice.input_device` in config.yaml (ffmpeg dshow device name)."
                .into(),
        );
    }
    if matches!(stop_mode, VoiceRecordingStopMode::AutoSilence)
        && !recorder_supports_auto_silence_stop(backend)
    {
        return Err(format!(
            "Continuous voice capture is unavailable with {}: {}.",
            describe_audio_recorder(backend),
            recorder_auto_silence_support_message(backend)
        ));
    }
    Ok(backend)
}

fn spawn_audio_recorder(
    path: &std::path::Path,
    input_device: Option<&str>,
    profile: VoiceRecordingProfile,
) -> anyhow::Result<(std::process::Child, AudioRecorderBackend)> {
    let backend =
        preferred_audio_recorder().ok_or_else(|| anyhow::anyhow!("no supported recorder found"))?;
    let mut command = match backend {
        AudioRecorderBackend::FfmpegAvFoundation => {
            let mut command = std::process::Command::new("ffmpeg");
            command.args([
                "-hide_banner",
                "-loglevel",
                "error",
                "-f",
                "avfoundation",
                "-i",
                input_device.unwrap_or(":default"),
                "-ac",
                "1",
                "-ar",
                "16000",
                "-y",
            ]);
            if matches!(profile.stop_mode, VoiceRecordingStopMode::AutoSilence) {
                command.args([
                    "-af",
                    &format!(
                        "silencedetect=n={}:d={:.2}",
                        format_silencedetect_threshold(profile.silence_threshold_db),
                        profile.silence_duration_ms as f64 / 1000.0
                    ),
                ]);
            }
            command
        }
        AudioRecorderBackend::FfmpegDShow => {
            let mut command = std::process::Command::new("ffmpeg");
            command.args([
                "-hide_banner",
                "-loglevel",
                "error",
                "-f",
                "dshow",
                "-i",
                input_device.unwrap_or("audio=default"),
                "-ac",
                "1",
                "-ar",
                "16000",
                "-y",
            ]);
            command
        }
        AudioRecorderBackend::Arecord => {
            let mut command = std::process::Command::new("arecord");
            command.args(["-q", "-f", "S16_LE", "-r", "16000", "-c", "1"]);
            command
        }
        AudioRecorderBackend::Rec => {
            let mut command = std::process::Command::new("rec");
            command.args(["-q", "-c", "1", "-r", "16000"]);
            if matches!(profile.stop_mode, VoiceRecordingStopMode::AutoSilence) {
                let threshold = format!("{:.1}d", profile.silence_threshold_db);
                let duration = format!("{:.2}", profile.silence_duration_ms as f64 / 1000.0);
                command.arg("silence");
                command.arg("1");
                command.arg("0.10");
                command.arg(&threshold);
                command.arg("1");
                command.arg(&duration);
                command.arg(&threshold);
            }
            command
        }
        AudioRecorderBackend::FfmpegPulse => {
            let mut command = std::process::Command::new("ffmpeg");
            command.args([
                "-hide_banner",
                "-loglevel",
                "error",
                "-f",
                "pulse",
                "-i",
                "default",
                "-ac",
                "1",
                "-ar",
                "16000",
                "-y",
            ]);
            if matches!(profile.stop_mode, VoiceRecordingStopMode::AutoSilence) {
                command.args([
                    "-af",
                    &format!(
                        "silencedetect=n={}:d={:.2}",
                        format_silencedetect_threshold(profile.silence_threshold_db),
                        profile.silence_duration_ms as f64 / 1000.0
                    ),
                ]);
            }
            command
        }
    };
    command.arg(path);
    command.stdin(std::process::Stdio::null());
    command.stdout(std::process::Stdio::null());
    let use_ffmpeg_monitor = matches!(
        (backend, profile.stop_mode),
        (
            AudioRecorderBackend::FfmpegAvFoundation | AudioRecorderBackend::FfmpegPulse,
            VoiceRecordingStopMode::AutoSilence
        )
    );
    command.stderr(if use_ffmpeg_monitor {
        std::process::Stdio::piped()
    } else {
        std::process::Stdio::null()
    });
    let mut child = command.spawn()?;
    if use_ffmpeg_monitor && let Some(stderr) = child.stderr.take() {
        spawn_ffmpeg_silence_monitor(child.id(), stderr);
    }
    Ok((child, backend))
}

fn stop_audio_recorder(child: &mut std::process::Child) -> anyhow::Result<()> {
    if interrupt_process_id(child.id()) {
        for _ in 0..20 {
            if child.try_wait()?.is_some() {
                return Ok(());
            }
            std::thread::sleep(Duration::from_millis(100));
        }
    }

    if child.try_wait()?.is_none() {
        child.kill()?;
        let _ = child.wait()?;
    }
    Ok(())
}

fn terminal_bell(count: usize) {
    let mut stdout = io::stdout();
    for _ in 0..count {
        let _ = stdout.write_all(b"\x07");
    }
    let _ = stdout.flush();
}

fn normalize_voice_transcript(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn is_probable_voice_hallucination(text: &str, capture_duration_secs: f64) -> bool {
    if capture_duration_secs > 1.75 {
        return false;
    }
    let normalized = normalize_voice_transcript(text).to_ascii_lowercase();
    matches!(
        normalized.as_str(),
        "thank you"
            | "thanks for watching"
            | "thanks for watching."
            | "thank you for watching"
            | "thank you for watching."
            | "you"
            | "music"
            | "[music]"
            | "(music)"
    )
}

fn key_matches_binding(key: &event::KeyEvent, binding: &str) -> bool {
    let normalized = binding.trim().to_ascii_lowercase().replace(' ', "");
    if normalized.is_empty() {
        return false;
    }

    let parts: Vec<&str> = normalized
        .split('+')
        .filter(|part| !part.is_empty())
        .collect();
    if parts.is_empty() {
        return false;
    }

    let mut expected_modifiers = KeyModifiers::NONE;
    for modifier in &parts[..parts.len().saturating_sub(1)] {
        match *modifier {
            "ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
            "alt" | "option" => expected_modifiers |= KeyModifiers::ALT,
            "shift" => expected_modifiers |= KeyModifiers::SHIFT,
            _ => return false,
        }
    }

    let code_matches = match parts[parts.len() - 1] {
        "enter" | "return" => key.code == KeyCode::Enter,
        "esc" | "escape" => key.code == KeyCode::Esc,
        "tab" => key.code == KeyCode::Tab,
        "backspace" => key.code == KeyCode::Backspace,
        "space" => key.code == KeyCode::Char(' '),
        token if token.starts_with('f') => token[1..]
            .parse::<u8>()
            .ok()
            .is_some_and(|function| key.code == KeyCode::F(function)),
        token if token.len() == 1 => {
            let expected = token.chars().next().unwrap_or_default();
            matches!(key.code, KeyCode::Char(actual) if actual.to_ascii_lowercase() == expected)
        }
        _ => false,
    };

    code_matches && key.modifiers == expected_modifiers
}

fn current_launch_cli_args() -> CliArgs {
    CliArgs::try_parse_from(std::env::args_os())
        .unwrap_or_else(|_| CliArgs::parse_from(["edgecrab"]))
}

fn voice_readback_ready() -> Result<AudioPlayerSpec, String> {
    if !TextToSpeechTool.is_available() {
        return Err(
            "No TTS backend available. Install `edge-tts`, set `OPENAI_API_KEY`, or configure ElevenLabs credentials."
                .into(),
        );
    }

    preferred_audio_player().ok_or_else(|| {
        "No supported local audio player found. Install one of: `afplay`, `ffplay`, `mpv`, `paplay`, `aplay`, or `play`."
            .into()
    })
}

async fn play_audio_file(path: &std::path::Path) -> anyhow::Result<&'static str> {
    let player = preferred_audio_player()
        .ok_or_else(|| anyhow::anyhow!("no supported local audio player found"))?;
    let status = tokio::process::Command::new(player.program)
        .args(player.args)
        .arg(path)
        .status()
        .await?;
    if !status.success() {
        anyhow::bail!("{} exited with status {}", player.program, status);
    }
    Ok(player.program)
}

/// Recursively copy a directory tree from `src` to `dst`.
/// Returns the count of files copied, or an IO error.
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<usize> {
    std::fs::create_dir_all(dst)?;
    let mut count = 0;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        if ty.is_dir() {
            count += copy_dir_recursive(&entry.path(), &dst.join(entry.file_name()))?;
        } else {
            std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
            count += 1;
        }
    }
    Ok(count)
}

fn load_config_root_for_edit() -> anyhow::Result<(std::path::PathBuf, serde_yml::Value)> {
    let config_path = edgecrab_core::edgecrab_home().join("config.yaml");
    if let Some(parent) = config_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
    let mut root: serde_yml::Value =
        serde_yml::from_str(&content).unwrap_or(serde_yml::Value::Mapping(Default::default()));

    if !matches!(root, serde_yml::Value::Mapping(_)) {
        root = serde_yml::Value::Mapping(Default::default());
    }

    Ok((config_path, root))
}

fn write_config_root(config_path: &std::path::Path, root: &serde_yml::Value) -> anyhow::Result<()> {
    let yaml = serde_yml::to_string(root)?;
    let header = "# EdgeCrab configuration\n\
                  # Edit this file to customize your setup.\n\
                  # Run `edgecrab doctor` to validate.\n\n";
    std::fs::write(config_path, format!("{header}{yaml}"))?;
    Ok(())
}

fn update_voice_config_in_config_root<F>(mutator: F) -> anyhow::Result<()>
where
    F: FnOnce(&mut serde_yml::Mapping),
{
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let voice_key = serde_yml::Value::String("voice".into());
        let voice_section = map
            .entry(voice_key)
            .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
        if let serde_yml::Value::Mapping(voice_map) = voice_section {
            mutator(voice_map);
        }
    }

    write_config_root(&config_path, &root)
}

/// Persist the user's model choice to ~/.edgecrab/config.yaml.
fn persist_model_to_config(model: &str) -> anyhow::Result<()> {
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let model_key = serde_yml::Value::String("model".into());
        let dm_key = serde_yml::Value::String("default".into());
        let legacy_dm_key = serde_yml::Value::String("default_model".into());
        let model_section = map
            .entry(model_key)
            .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
        if let serde_yml::Value::Mapping(m) = model_section {
            m.insert(dm_key, serde_yml::Value::String(model.into()));
            m.remove(&legacy_dm_key);
        }
    }

    write_config_root(&config_path, &root)
}

/// Persist smart-routing cheap-model defaults to ~/.edgecrab/config.yaml.
fn persist_smart_routing_to_config(
    smart_routing: &edgecrab_core::config::SmartRoutingYaml,
) -> anyhow::Result<()> {
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let model_key = serde_yml::Value::String("model".into());
        let smart_routing_key = serde_yml::Value::String("smart_routing".into());
        let enabled_key = serde_yml::Value::String("enabled".into());
        let cheap_model_key = serde_yml::Value::String("cheap_model".into());
        let cheap_base_url_key = serde_yml::Value::String("cheap_base_url".into());
        let cheap_api_key_env_key = serde_yml::Value::String("cheap_api_key_env".into());

        let model_section = map
            .entry(model_key)
            .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
        if let serde_yml::Value::Mapping(model_map) = model_section {
            let has_smart_routing = smart_routing.enabled
                || !smart_routing.cheap_model.trim().is_empty()
                || smart_routing
                    .cheap_base_url
                    .as_deref()
                    .is_some_and(|value| !value.trim().is_empty())
                || smart_routing
                    .cheap_api_key_env
                    .as_deref()
                    .is_some_and(|value| !value.trim().is_empty());

            if has_smart_routing {
                let section = model_map
                    .entry(smart_routing_key.clone())
                    .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
                if let serde_yml::Value::Mapping(sr_map) = section {
                    sr_map.insert(enabled_key, serde_yml::Value::Bool(smart_routing.enabled));
                    if smart_routing.cheap_model.trim().is_empty() {
                        sr_map.remove(&cheap_model_key);
                    } else {
                        sr_map.insert(
                            cheap_model_key,
                            serde_yml::Value::String(smart_routing.cheap_model.clone()),
                        );
                    }
                    if let Some(base_url) = smart_routing
                        .cheap_base_url
                        .as_deref()
                        .filter(|value| !value.trim().is_empty())
                    {
                        sr_map.insert(
                            cheap_base_url_key,
                            serde_yml::Value::String(base_url.to_string()),
                        );
                    } else {
                        sr_map.remove(&cheap_base_url_key);
                    }
                    if let Some(api_key_env) = smart_routing
                        .cheap_api_key_env
                        .as_deref()
                        .filter(|value| !value.trim().is_empty())
                    {
                        sr_map.insert(
                            cheap_api_key_env_key,
                            serde_yml::Value::String(api_key_env.to_string()),
                        );
                    } else {
                        sr_map.remove(&cheap_api_key_env_key);
                    }
                }
            } else {
                model_map.remove(&smart_routing_key);
            }
        }
    }

    write_config_root(&config_path, &root)
}

/// Persist the auxiliary vision-model routing to ~/.edgecrab/config.yaml.
fn persist_vision_model_to_config(
    auxiliary: &edgecrab_core::config::AuxiliaryConfig,
) -> anyhow::Result<()> {
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let auxiliary_key = serde_yml::Value::String("auxiliary".into());
        let provider_key = serde_yml::Value::String("provider".into());
        let model_key = serde_yml::Value::String("model".into());
        let base_url_key = serde_yml::Value::String("base_url".into());
        let api_key_env_key = serde_yml::Value::String("api_key_env".into());

        let has_auxiliary = auxiliary
            .provider
            .as_deref()
            .is_some_and(|v| !v.trim().is_empty())
            || auxiliary
                .model
                .as_deref()
                .is_some_and(|v| !v.trim().is_empty())
            || auxiliary
                .base_url
                .as_deref()
                .is_some_and(|v| !v.trim().is_empty())
            || auxiliary
                .api_key_env
                .as_deref()
                .is_some_and(|v| !v.trim().is_empty());

        if has_auxiliary {
            let auxiliary_section = map
                .entry(auxiliary_key.clone())
                .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
            if let serde_yml::Value::Mapping(aux_map) = auxiliary_section {
                if let Some(provider) = auxiliary
                    .provider
                    .as_deref()
                    .filter(|v| !v.trim().is_empty())
                {
                    aux_map.insert(provider_key, serde_yml::Value::String(provider.to_string()));
                } else {
                    aux_map.remove(&provider_key);
                }
                if let Some(model) = auxiliary.model.as_deref().filter(|v| !v.trim().is_empty()) {
                    aux_map.insert(model_key, serde_yml::Value::String(model.to_string()));
                } else {
                    aux_map.remove(&model_key);
                }
                if let Some(base_url) = auxiliary
                    .base_url
                    .as_deref()
                    .filter(|v| !v.trim().is_empty())
                {
                    aux_map.insert(base_url_key, serde_yml::Value::String(base_url.to_string()));
                } else {
                    aux_map.remove(&base_url_key);
                }
                if let Some(api_key_env) = auxiliary
                    .api_key_env
                    .as_deref()
                    .filter(|v| !v.trim().is_empty())
                {
                    aux_map.insert(
                        api_key_env_key,
                        serde_yml::Value::String(api_key_env.to_string()),
                    );
                } else {
                    aux_map.remove(&api_key_env_key);
                }
            }
        } else {
            map.remove(&auxiliary_key);
        }
    }

    write_config_root(&config_path, &root)
}

/// Persist the default image-generation routing to ~/.edgecrab/config.yaml.
fn persist_image_generation_to_config(
    image_generation: &edgecrab_core::config::ImageGenerationConfig,
) -> anyhow::Result<()> {
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let section_key = serde_yml::Value::String("image_generation".into());
        let provider_key = serde_yml::Value::String("provider".into());
        let model_key = serde_yml::Value::String("model".into());

        let section = map
            .entry(section_key)
            .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
        if let serde_yml::Value::Mapping(image_map) = section {
            image_map.insert(
                provider_key,
                serde_yml::Value::String(image_generation.provider.clone()),
            );
            image_map.insert(
                model_key,
                serde_yml::Value::String(image_generation.model.clone()),
            );
        }
    }

    write_config_root(&config_path, &root)
}

/// Persist Mixture-of-Agents defaults to ~/.edgecrab/config.yaml.
fn persist_moa_config_to_config(moa: &edgecrab_core::config::MoaConfig) -> anyhow::Result<()> {
    let moa = moa.sanitized();
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let section_key = serde_yml::Value::String("moa".into());
        let enabled_key = serde_yml::Value::String("enabled".into());
        let reference_models_key = serde_yml::Value::String("reference_models".into());
        let aggregator_model_key = serde_yml::Value::String("aggregator_model".into());

        let section = map
            .entry(section_key)
            .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
        if let serde_yml::Value::Mapping(moa_map) = section {
            moa_map.insert(enabled_key, serde_yml::Value::Bool(moa.enabled));
            moa_map.insert(
                aggregator_model_key,
                serde_yml::Value::String(moa.aggregator_model.clone()),
            );
            moa_map.insert(
                reference_models_key,
                serde_yml::Value::Sequence(
                    moa.reference_models
                        .iter()
                        .map(|model| serde_yml::Value::String(model.clone()))
                        .collect(),
                ),
            );
        }
    }

    write_config_root(&config_path, &root)
}

fn persist_toolset_filters_to_config(
    enabled_toolsets: Option<&[String]>,
    disabled_toolsets: Option<&[String]>,
) -> anyhow::Result<()> {
    persist_tool_filters_to_config(enabled_toolsets, disabled_toolsets, None, None)
}

fn persist_tool_filters_to_config(
    enabled_toolsets: Option<&[String]>,
    disabled_toolsets: Option<&[String]>,
    enabled_tools: Option<&[String]>,
    disabled_tools: Option<&[String]>,
) -> anyhow::Result<()> {
    let (config_path, mut root) = load_config_root_for_edit()?;

    if let serde_yml::Value::Mapping(ref mut map) = root {
        let tools_key = serde_yml::Value::String("tools".into());
        let enabled_key = serde_yml::Value::String("enabled_toolsets".into());
        let disabled_key = serde_yml::Value::String("disabled_toolsets".into());
        let enabled_tools_key = serde_yml::Value::String("enabled_tools".into());
        let disabled_tools_key = serde_yml::Value::String("disabled_tools".into());

        let tools_section = map
            .entry(tools_key)
            .or_insert_with(|| serde_yml::Value::Mapping(Default::default()));
        if let serde_yml::Value::Mapping(tools_map) = tools_section {
            let write_list = |tools_map: &mut serde_yml::Mapping,
                              key: serde_yml::Value,
                              values: Option<&[String]>| {
                if let Some(values) = values.filter(|values| !values.is_empty()) {
                    tools_map.insert(
                        key,
                        serde_yml::Value::Sequence(
                            values
                                .iter()
                                .map(|value| serde_yml::Value::String(value.clone()))
                                .collect(),
                        ),
                    );
                } else {
                    tools_map.remove(&key);
                }
            };

            write_list(tools_map, enabled_key, enabled_toolsets);
            write_list(tools_map, disabled_key, disabled_toolsets);
            write_list(tools_map, enabled_tools_key, enabled_tools);
            write_list(tools_map, disabled_tools_key, disabled_tools);
        }
    }

    write_config_root(&config_path, &root)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MoaAvailability {
    feature_enabled: bool,
    toolset_enabled: bool,
}

impl MoaAvailability {
    fn effective(self) -> bool {
        self.feature_enabled && self.toolset_enabled
    }
}

fn toolset_entries_referencing(target_toolset: &str, entries: &[String]) -> Vec<String> {
    entries
        .iter()
        .filter(|entry| {
            if entry.as_str() == target_toolset {
                return true;
            }
            edgecrab_tools::toolsets::resolve_alias(entry)
                .is_some_and(|expanded| expanded.contains(&target_toolset))
        })
        .cloned()
        .collect()
}

fn normalize_tool_policy_list(values: &mut Vec<String>) {
    values.sort();
    values.dedup();
}

/// Persist the CLI voice-mode default to ~/.edgecrab/config.yaml.
fn persist_voice_enabled_to_config(enabled: bool) -> anyhow::Result<()> {
    update_voice_config_in_config_root(|voice_map| {
        let enabled_key = serde_yml::Value::String("enabled".into());
        voice_map.insert(enabled_key, serde_yml::Value::Bool(enabled));
    })
}

fn persist_voice_preferences_to_config(
    continuous: Option<bool>,
    hallucination_filter: Option<bool>,
    input_device: Option<Option<&str>>,
) -> anyhow::Result<()> {
    update_voice_config_in_config_root(|voice_map| {
        if let Some(continuous) = continuous {
            voice_map.insert(
                serde_yml::Value::String("continuous".into()),
                serde_yml::Value::Bool(continuous),
            );
        }
        if let Some(enabled) = hallucination_filter {
            voice_map.insert(
                serde_yml::Value::String("hallucination_filter".into()),
                serde_yml::Value::Bool(enabled),
            );
        }
        if let Some(input_device) = input_device {
            let input_key = serde_yml::Value::String("input_device".into());
            if let Some(input_device) = input_device.filter(|value| !value.trim().is_empty()) {
                voice_map.insert(
                    input_key,
                    serde_yml::Value::String(input_device.to_string()),
                );
            } else {
                voice_map.remove(&input_key);
            }
        }
    })
}

#[derive(Debug, Clone, Copy)]
struct DisplayPreferences {
    show_reasoning: bool,
    streaming_enabled: bool,
    tool_progress_mode: ToolProgressMode,
    show_status_bar: bool,
}

impl Default for DisplayPreferences {
    fn default() -> Self {
        Self {
            show_reasoning: false,
            streaming_enabled: true,
            tool_progress_mode: ToolProgressMode::Verbose,
            show_status_bar: true,
        }
    }
}

/// Load the persisted display preferences from config.
fn load_display_preferences() -> DisplayPreferences {
    edgecrab_core::AppConfig::load()
        .map(|cfg| DisplayPreferences {
            show_reasoning: cfg.display.show_reasoning,
            streaming_enabled: cfg.model.streaming && cfg.display.streaming,
            tool_progress_mode: cfg.display.tool_progress,
            show_status_bar: cfg.display.show_status_bar,
        })
        .unwrap_or_default()
}

fn load_voice_mode_enabled() -> bool {
    edgecrab_core::AppConfig::load()
        .map(|cfg| cfg.voice.enabled)
        .unwrap_or(false)
}

/// Persist display preferences to `config.yaml`.
fn persist_display_preferences(
    show_reasoning: Option<bool>,
    streaming_enabled: Option<bool>,
    tool_progress_mode: Option<ToolProgressMode>,
    show_status_bar: Option<bool>,
) -> anyhow::Result<()> {
    let mut config = edgecrab_core::AppConfig::load().unwrap_or_default();
    if let Some(enabled) = show_reasoning {
        config.display.show_reasoning = enabled;
    }
    if let Some(enabled) = streaming_enabled {
        config.model.streaming = enabled;
        config.display.streaming = enabled;
    }
    if let Some(mode) = tool_progress_mode {
        config.display.tool_progress = mode;
    }
    if let Some(enabled) = show_status_bar {
        config.display.show_status_bar = enabled;
    }
    config.save()?;
    Ok(())
}

fn persist_worktree_enabled_to_config(enabled: bool) -> anyhow::Result<()> {
    let mut config = edgecrab_core::AppConfig::load().unwrap_or_default();
    config.worktree = enabled;
    config.save()?;
    Ok(())
}

// ─── Spinner frames (braille rotation) ──────────────────────────────
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const ASCII_SPINNER_FRAMES: &[&str] = &["-", "\\", "|", "/"];
const VOICE_LISTEN_FRAMES: &[&str] = &[".  ", ".. ", "...", " ..", "  .", "   "];
const VOICE_RECORD_FRAMES: &[&str] = &["*  ", "** ", "***", " **", "  *", "   "];
const VOICE_PLAYBACK_FRAMES: &[&str] = &[">  ", ">> ", ">>>", " >>", "  >", "   "];

/// Fixed display-column width for the thinking verb in the status bar.
/// Padding to this width prevents horizontal jitter as words rotate during animation.
/// Value = longest default verb ("hypothesizing" / "extrapolating" / "orchestrating" = 13).
const VERB_DISPLAY_PAD: usize = 13;

// THINKING_VERBS: the Theme now supplies thinking_verbs from SkinConfig
// (defaulting to DEFAULT_THINKING_VERBS in theme.rs). This constant is kept
// for reference / tests only and is no longer used in render_status_bar.
#[allow(dead_code)]
const THINKING_VERBS: &[&str] = &[
    "pondering",
    "contemplating",
    "reasoning",
    "analyzing",
    "computing",
    "synthesizing",
    "formulating",
    "processing",
    "deliberating",
    "mulling",
    "cogitating",
    "ruminating",
    "brainstorming",
    "reflecting",
    "deducing",
    "hypothesizing",
    "extrapolating",
    "orchestrating",
    "calibrating",
    "optimizing",
];

// ─── Kaomoji pools for tool completion display ────────────────────────
// Inspired by hermes-agent's KAWAII_* arrays.
// Single-width characters only — no wide emoji — for safe non-ratatui contexts.

/// Pad `s` to `target_display_cols` columns using unicode display width.
/// Safe for strings containing emoji and multi-width codepoints.
/// Returns `s` unchanged if it already meets or exceeds the target.
fn unicode_pad_right(s: &str, target_display_cols: usize) -> String {
    let w = s.width();
    if w >= target_display_cols {
        return s.to_string();
    }
    format!("{}{}", s, " ".repeat(target_display_cols - w))
}

fn phase_face(faces: &[String], idx: usize) -> &str {
    if faces.is_empty() {
        ""
    } else {
        faces[idx % faces.len()].as_str()
    }
}

fn phase_wings(wings: &[[String; 2]], idx: usize) -> (&str, &str) {
    if wings.is_empty() {
        ("", "")
    } else {
        let wing = &wings[idx % wings.len()];
        (wing[0].as_str(), wing[1].as_str())
    }
}

fn format_phase_status(
    spinner: &str,
    verb: &str,
    face: &str,
    wings: (&str, &str),
    elapsed_secs: u64,
    early_label: &str,
    long_label: &str,
) -> String {
    let verb_padded = unicode_pad_right(verb, VERB_DISPLAY_PAD);
    let core = if face.is_empty() {
        format!("{spinner} {verb_padded}")
    } else {
        format!("{spinner} {face} {verb_padded}")
    };
    let (left_wing, right_wing) = wings;
    if elapsed_secs > 20 {
        // Stall tier: add ⚠ prefix — likely network issue or rate-limit queue.
        format!("{left_wing}{core} \u{26a0} {long_label} {elapsed_secs}s  ^C=stop{right_wing}")
    } else if elapsed_secs > 10 {
        format!("{left_wing}{core} {long_label} {elapsed_secs}s  ^C=stop{right_wing}")
    } else if elapsed_secs > 3 {
        format!("{left_wing}{core} {long_label} {elapsed_secs}s{right_wing}")
    } else if elapsed_secs > 1 {
        format!("{left_wing}{core} {early_label}{right_wing}")
    } else {
        format!("{left_wing}{core}{right_wing}")
    }
}

/// Map wait elapsed seconds to an urgency color (FP46).
/// - <15s: amber (normal wait)
/// - 15-29s: orange (slow, pay attention)
/// - ≥30s: red (stall — likely network issue or rate-limit queue)
fn wait_urgency_color(elapsed_secs: u64) -> Color {
    if elapsed_secs >= 30 {
        Color::Rgb(239, 83, 80) // red — stall
    } else if elapsed_secs >= 15 {
        Color::Rgb(255, 140, 50) // orange — slow
    } else {
        Color::Rgb(255, 210, 120) // amber — normal
    }
}

fn format_waiting_first_token_status(
    theme: &Theme,
    frame_idx: usize,
    verb_idx: usize,
    face_idx: usize,
    elapsed_secs: u64,
) -> String {
    let spinner = SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.len()];
    let verb = if theme.waiting_verbs.is_empty() {
        "awaiting"
    } else {
        &theme.waiting_verbs[verb_idx % theme.waiting_verbs.len()]
    };
    let face = phase_face(&theme.kaomoji_waiting, face_idx);
    let wings = phase_wings(&theme.spinner_wings, face_idx);
    format_phase_status(
        spinner,
        verb,
        face,
        wings,
        elapsed_secs,
        "first token",
        "waiting for first token",
    )
}

fn format_thinking_status(
    theme: &Theme,
    frame_idx: usize,
    verb_idx: usize,
    face_idx: usize,
    elapsed_secs: u64,
) -> String {
    let spinner = SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.len()];
    let verb = if theme.thinking_verbs.is_empty() {
        "thinking"
    } else {
        &theme.thinking_verbs[verb_idx % theme.thinking_verbs.len()]
    };
    let face = phase_face(&theme.kaomoji_thinking, face_idx);
    let wings = phase_wings(&theme.spinner_wings, face_idx);
    format_phase_status(
        spinner,
        verb,
        face,
        wings,
        elapsed_secs,
        "thinking",
        "thinking",
    )
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VoicePresenceState {
    Recording { elapsed_secs: u64, continuous: bool },
    Speaking,
    Listening,
}

fn format_voice_presence_badge(state: VoicePresenceState, frame_idx: usize) -> String {
    match state {
        VoicePresenceState::Recording {
            elapsed_secs,
            continuous,
        } => {
            let meter = VOICE_RECORD_FRAMES[frame_idx % VOICE_RECORD_FRAMES.len()];
            let label = if continuous { "TALK" } else { "REC" };
            format!(" {label} {meter} {elapsed_secs}s ")
        }
        VoicePresenceState::Speaking => {
            let meter = VOICE_PLAYBACK_FRAMES[frame_idx % VOICE_PLAYBACK_FRAMES.len()];
            format!(" SPEAK {meter} ")
        }
        VoicePresenceState::Listening => {
            let meter = VOICE_LISTEN_FRAMES[frame_idx % VOICE_LISTEN_FRAMES.len()];
            format!(" LISTEN {meter} ")
        }
    }
}

/// Truncate `s` to at most `max_cols` display columns (unicode-safe).
/// Appends "..." if truncation occurred.
fn unicode_trunc(s: &str, max_cols: usize) -> String {
    let w = s.width();
    if w <= max_cols {
        return s.to_string();
    }
    // Walk chars until we would exceed max_cols - 3 (for "...")
    let budget = max_cols.saturating_sub(3);
    let mut out = String::new();
    let mut used = 0usize;
    for ch in s.chars() {
        let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
        if used + cw > budget {
            break;
        }
        out.push(ch);
        used += cw;
    }
    out.push_str("...");
    out
}

/// Build the `*** ATTACHED IMAGES ***` injection block for a list of local image paths.
///
/// WHY extracted: Both CLI (clipboard paste via `pending_images`) and gateway platforms
/// (WhatsApp, Telegram, …) must produce the exact same block format so that
/// `VISION_GUIDANCE` in the system prompt fires consistently on every turn.
/// Gateway uses the mirror function `platform::format_image_attachment_block` in
/// `edgecrab-gateway`. Any change to the format here must be kept in sync there.
fn format_image_attachment_block(image_paths: &[&str]) -> String {
    let image_list = image_paths.join(", ");
    let count = image_paths.len();
    format!(
        "*** ATTACHED IMAGES \u{2014} ACTION REQUIRED ***\n\
         The user has attached {count} image file(s): {image_list}\n\
         You MUST call vision_analyze for EACH image before responding.\n\
         - Use tool: vision_analyze\n\
         - Parameter: image_source = <the file path above>\n\
         - DO NOT use browser_vision (that captures web pages, not local files)\n\
         - DO NOT use read_file on image paths (binary files)\n\
         *** END ATTACHED IMAGES ***"
    )
}

/// A single line in the output area with a semantic role.
#[derive(Clone)]
pub struct OutputLine {
    pub text: String,
    pub role: OutputRole,
    /// Pre-built ratatui spans (for tool-done lines with emoji).
    /// When `Some`, these are used directly in render instead of re-parsing `text`.
    pub prebuilt_spans: Option<Vec<Span<'static>>>,
    /// Cached rendered lines (invalidated when text changes).
    rendered: Option<Vec<Line<'static>>>,
    /// Simplified cached lines for slow / remote terminals.
    plain_rendered: Option<Vec<Line<'static>>>,
}

#[derive(Clone, Copy, PartialEq)]
#[allow(dead_code)]
pub enum OutputRole {
    Assistant,
    Tool,
    System,
    Reasoning,
    Error,
    User,
}

impl OutputLine {
    fn invalidate_render_cache(&mut self) {
        self.rendered = None;
        self.plain_rendered = None;
    }
}

/// Display state machine for the spinner/status area.
#[derive(Clone, Debug)]
enum DisplayState {
    Idle,
    AwaitingFirstToken {
        frame: usize,
        started: Instant,
    },
    Thinking {
        frame: usize,
        started: Instant,
    },
    Streaming {
        token_count: u64,
        /// Accumulated character count for word-count estimation in the status bar.
        chars_written: u64,
        /// Most-recently detected markdown heading (level 1 or 2) in the stream.
        /// Updated whenever a `\n# ` or `\n## ` sequence completes. Gives users a
        /// semantic progress landmark during long document generation.
        current_section: Option<String>,
        started: Instant,
    },
    #[allow(dead_code)]
    ToolExec {
        tool_call_id: String,
        name: String,
        args_json: String,
        detail: Option<String>,
        frame: usize,
        started: Instant,
    },
    /// A background I/O operation is in progress (e.g. model discovery).
    /// Shows a spinner with a label in the status bar. Does NOT block user input.
    BgOp {
        label: String,
        frame: usize,
        started: Instant,
    },
    /// The agent sent a clarifying question and is waiting for the user to reply.
    ///
    /// WHY separate from Idle: when `is_processing` is true but the state is
    /// Idle, the status bar shows nothing — users think the agent hung.  This
    /// variant lets `render_status_bar` display "❓ Waiting for reply" so the
    /// interaction intent is always clear even before the user reads the question.
    WaitingForClarify,
    /// The agent is requesting risk-graduated approval before executing a command.
    ///
    /// WHY separate: mirrors `WaitingForClarify` but routes keyboard input to the
    /// approval overlay (← → navigate choices, Enter confirm) rather than the text
    /// input area.  When active, the main input is locked and keybindings are
    /// redirected here.
    WaitingForApproval {
        /// Display label (full command — no longer truncated).
        command: String,
        /// Full command string (kept for backward compatibility; identical to `command`).
        full_command: String,
        /// Currently highlighted choice index (0–3 in [Once, Session, Always, Deny]).
        selected: usize,
        /// Whether the "view" mode is active (legacy — kept for key-handler symmetry).
        show_full: bool,
        /// Vertical scroll offset for long commands that exceed the visible area.
        scroll_offset: u16,
    },
    /// The agent is requesting a secret value from the user (e.g. an API key
    /// or sudo password).
    ///
    /// WHY separate: a masked-input overlay (`•••`) must replace the normal
    /// textarea so the secret never appears in scrollback. Keybindings are
    /// intercepted and the buffer is cleared immediately after sending.
    SecretCapture {
        /// Variable name or credential label shown in the overlay title.
        var_name: String,
        /// Human-readable prompt displayed inside the overlay.
        prompt: String,
        /// Whether this is a privilege-escalation (sudo) prompt — affects colour.
        is_sudo: bool,
        /// Currently typed buffer (never stored in history or output).
        buffer: String,
    },
    /// Local inline configuration prompt used by the gateway browser.
    ValueCapture {
        title: String,
        prompt: String,
        placeholder: String,
        masked: bool,
        buffer: String,
        action: ValueCaptureAction,
    },
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum ValueCaptureAction {
    BindAddress,
    HomeChannel(String),
    AllowedUsers(String),
    PrimaryField(String),
    ProfileCreate,
    ProfileRename(String),
    ProfileDeleteConfirm(String),
    ProfileAlias(String),
    ProfileExport(String),
    ProfileImport,
}

/// Result payload delivered back to the main loop via `AgentResponse::BgOp`
/// once a spawned background task completes.
enum BackgroundOpResult {
    /// Model catalog loaded — open the model selector pre-focused on current.
    ModelCatalogReady {
        models: Vec<ModelEntry>,
        current_model: String,
    },
    /// Free-form text to push to the output pane (System role).
    SystemMsg(String),
    /// Gateway runtime command completed and the browser should refresh.
    GatewayCommandDone { report: String },
    /// Gateway diagnostics ready — open the full-screen overlay.
    DiagnoseReady { report: String },
    /// Provider swap succeeded — update model name and persist config.
    ModelSwitchDone { model: String },
    /// Context compression finished — show summary message.
    CompressDone { msg: String },
}

/// Tab-completion overlay state.
struct CompletionState {
    /// (name, description) — either a command token or a subcommand token.
    candidates: Vec<(String, String)>,
    selected: usize,
    active: bool,
    /// Byte offset in the textarea where the "current token" starts.
    ///
    /// * `0`   → completing the **command token** (text before the first space);
    ///   `accept_completion` replaces the command while preserving the
    ///   argument tail.
    /// * `> 0` → completing an **argument / subcommand token** that starts at
    ///   this offset; `accept_completion` keeps `text[..arg_start]`
    ///   verbatim and replaces only the fragment that follows it.
    arg_start: usize,
}

/// Full-screen Gateway Diagnostics overlay panel.
///
/// SOLID: single responsibility — holds only rendering/scrolling state for the
/// diagnose overlay. All data production lives in `gateway_cmd::diagnose_report`.
#[derive(Debug, Default)]
struct DiagnosePanelState {
    /// Whether the overlay is currently visible.
    active: bool,
    /// Raw report string returned by `diagnose_report()`.
    report: String,
    /// Current vertical scroll offset (in display lines).
    scroll: usize,
    /// Total number of lines in the report (for scroll clamping).
    total_lines: usize,
    /// True while a background refresh is in-flight.
    refresh_in_flight: bool,
}

impl CompletionState {
    /// Compute the visible window for the completion popup.
    ///
    /// The inline slash-command selector should behave like the full-screen
    /// selectors: once the highlighted row moves past the visible height, the
    /// viewport must advance so the current selection stays on-screen.
    #[allow(dead_code)]
    fn visible_window(&self, max_visible: usize) -> (usize, usize) {
        if self.candidates.is_empty() || max_visible == 0 {
            return (0, 0);
        }

        let selected = self.selected.min(self.candidates.len() - 1);
        let start = if selected >= max_visible {
            selected - max_visible + 1
        } else {
            0
        };
        let end = (start + max_visible).min(self.candidates.len());
        (start, end)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputEditorMode {
    Inline,
    ComposeInsert,
    ComposeNormal,
}

impl InputEditorMode {
    fn is_compose(self) -> bool {
        !matches!(self, Self::Inline)
    }

    fn input_title(self, prompt_symbol: &str) -> String {
        match self {
            Self::Inline => format!(" {} ", prompt_symbol.trim()),
            Self::ComposeInsert => " Compose INSERT ".to_string(),
            Self::ComposeNormal => " Compose NORMAL ".to_string(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimPending {
    Delete,
    Change,
    Yank,
    Go,
}

impl VimPending {
    fn operator_char(self) -> char {
        match self {
            Self::Delete => 'd',
            Self::Change => 'c',
            Self::Yank => 'y',
            Self::Go => 'g',
        }
    }

    fn input_title_suffix(self) -> &'static str {
        match self {
            Self::Delete => " [d]",
            Self::Change => " [c]",
            Self::Yank => " [y]",
            Self::Go => " [g]",
        }
    }

    fn status_hint(self) -> String {
        let op = self.operator_char();
        match self {
            Self::Go => " NORMAL [g]  g=top  G=bottom  Esc=cancel  ^S=send ".to_string(),
            Self::Delete | Self::Change | Self::Yank => {
                format!(
                    " NORMAL [{}]  w/b/e/$ or {op}{op}  Esc=cancel  ^S=send ",
                    op
                )
            }
        }
    }
}

/// A single model entry for the model selector overlay.
#[derive(Clone)]
struct ModelEntry {
    /// Provider/model display name (e.g. "openai/gpt-4o")
    display: String,
    /// Provider name (e.g. "openai")
    provider: String,
    /// Model name (e.g. "gpt-4o")
    model_name: String,
    /// Supplemental searchable/display text for selectors.
    detail: String,
}

impl FuzzyItem for ModelEntry {
    fn primary(&self) -> &str {
        &self.display
    }
    fn secondary(&self) -> &str {
        &self.detail
    }
    fn tag(&self) -> &str {
        &self.provider
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelSelectorTarget {
    Primary,
    Cheap,
    MoaAggregator,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MoaReferenceSelectorMode {
    EditRoster,
    AddExpert,
    RemoveExpert,
}

fn discovery_source_label(source: DiscoverySource) -> &'static str {
    match source {
        DiscoverySource::Live => "live discovery",
        DiscoverySource::Cache => "cached discovery",
        DiscoverySource::Static => "static catalog",
    }
}

fn discovery_availability_short(availability: DiscoveryAvailability) -> String {
    match availability {
        DiscoveryAvailability::Supported => "live discovery".to_string(),
        DiscoveryAvailability::FeatureGated(feature) => {
            format!("live discovery gated by `{feature}`")
        }
        DiscoveryAvailability::Unsupported => "static catalog".to_string(),
    }
}

fn discovery_availability_detail(provider: &str, availability: DiscoveryAvailability) -> String {
    match availability {
        DiscoveryAvailability::Supported => {
            format!("{provider} supports live discovery in this build.")
        }
        DiscoveryAvailability::FeatureGated(feature) => format!(
            "{provider} supports live discovery, but this build falls back to the embedded catalog because `{feature}` is disabled."
        ),
        DiscoveryAvailability::Unsupported => {
            format!("{provider} is served from the embedded catalog.")
        }
    }
}

fn build_model_selector_entries(
    grouped: &[(String, Vec<String>)],
    dynamic_lookup: Option<&BTreeMap<String, (DiscoverySource, BTreeSet<String>)>>,
) -> Vec<ModelEntry> {
    let mut all_models = Vec::new();
    for (provider, models) in grouped {
        for model in models {
            let detail = match dynamic_lookup.and_then(|lookup| lookup.get(provider)) {
                Some((source, discovered_models)) if discovered_models.contains(model) => {
                    format!("{model} · {}", discovery_source_label(*source))
                }
                Some((DiscoverySource::Static, _)) => {
                    format!(
                        "{model} · {}",
                        discovery_source_label(DiscoverySource::Static)
                    )
                }
                Some(_) => format!("{model} · catalog fallback"),
                None => format!(
                    "{model} · {}",
                    discovery_source_label(DiscoverySource::Static)
                ),
            };
            all_models.push(ModelEntry {
                display: format!("{provider}/{model}"),
                provider: provider.clone(),
                detail,
                model_name: model.clone(),
            });
        }
    }
    all_models.sort_by(|left, right| left.display.cmp(&right.display));
    all_models
}

fn build_models_inventory_report(
    providers: &[(String, Vec<String>)],
    current_model: &str,
    filter: &str,
) -> String {
    let current_provider = current_model
        .split_once('/')
        .map(|(provider, _)| normalize_discovery_provider(provider));
    let discovery_statuses: BTreeMap<String, DiscoveryAvailability> =
        discovery_provider_statuses().into_iter().collect();
    let mut text = if filter.is_empty() {
        "Model inventory (* = current provider):\n\n".to_string()
    } else {
        format!("Providers matching '{filter}' (* = current provider):\n\n")
    };

    for (provider, models) in providers {
        let label = ModelCatalog::provider_label(provider);
        let marker = if current_provider.as_deref() == Some(provider.as_str()) {
            " *"
        } else {
            ""
        };
        let availability = discovery_statuses
            .get(provider)
            .copied()
            .unwrap_or(DiscoveryAvailability::Unsupported);
        text.push_str(&format!(
            "  {provider:<12} {label:<22} {:>3} models  {}{marker}\n",
            models.len(),
            discovery_availability_short(availability),
        ));
    }

    text.push_str(
        "\nUse /models <provider> for the full list, /models refresh [provider|all] to refresh live inventories, or /model to open the selector.",
    );
    text
}

fn build_provider_models_report(
    provider_models: &ProviderModels,
    availability: DiscoveryAvailability,
    current_model: &str,
) -> String {
    let provider = &provider_models.provider;
    let label = ModelCatalog::provider_label(provider);
    let mut text = format!(
        "Models for '{provider}' (* = current):\n\n  Provider: {label}\n  Discovery: {}\n  Source: {}\n  Count: {}\n\n",
        discovery_availability_detail(provider, availability),
        discovery_source_label(provider_models.source),
        provider_models.models.len(),
    );

    if provider_models.models.is_empty() {
        text.push_str(match provider_models.source {
            DiscoverySource::Live | DiscoverySource::Cache => {
                "  (no text models returned from provider discovery)\n"
            }
            DiscoverySource::Static => "  (no models known)\n",
        });
    } else {
        for model in &provider_models.models {
            let full = format!("{provider}/{model}");
            let marker = if current_model == full { " *" } else { "" };
            text.push_str(&format!("  {full}{marker}\n"));
        }
    }

    text.push_str("\nSwitch with: /model <provider/model-name>");
    text
}

/// A single skill entry for the skill selector table.
#[derive(Clone)]
struct SkillEntry {
    name: String,
    display_name: String,
    is_dir: bool,
    active: bool,
    category: String,
    relative_path: String,
    desc: String,
    detail: String,
    detail_view: String,
    search_text: String,
}

impl SkillEntry {
    fn display_title(&self) -> String {
        let label = if self.display_name == self.name {
            format!("/{}", self.name)
        } else {
            format!("/{} ({})", self.name, self.display_name)
        };
        format!("[{}] {}", if self.active { "x" } else { " " }, label)
    }

    fn kind_label(&self) -> &'static str {
        if self.is_dir { "bundle" } else { "single" }
    }

    fn list_detail(&self) -> String {
        if !self.desc.is_empty() {
            return self.desc.clone();
        }
        format!("{} | {}", self.detail, self.relative_path)
    }

    fn detail_actions_line(&self) -> &'static str {
        if self.active {
            "Actions: Space deactivate, Enter insert /skill, R remote browser"
        } else {
            "Actions: Space activate, Enter insert /skill, R remote browser"
        }
    }
}

impl FuzzyItem for SkillEntry {
    fn primary(&self) -> &str {
        &self.name
    }
    fn secondary(&self) -> &str {
        &self.search_text
    }
    fn tag(&self) -> &str {
        &self.category
    }
}

#[derive(Clone)]
struct ProfileEntry {
    name: String,
    is_default: bool,
    is_active: bool,
    gateway_running: bool,
    model: String,
    detail: String,
    detail_view: String,
    search_text: String,
}

impl ProfileEntry {
    fn display_title(&self) -> String {
        let marker = if self.is_active { "*" } else { " " };
        format!("[{marker}] {}", self.name)
    }

    fn kind_label(&self) -> &'static str {
        if self.is_default { "default" } else { "named" }
    }

    fn status_label(&self) -> &'static str {
        if self.is_active {
            "active"
        } else if self.gateway_running {
            "running"
        } else {
            "ready"
        }
    }

    fn detail_actions_line(&self) -> &'static str {
        if self.is_active {
            "Actions: Enter reload, C config, S SOUL, M memory, T tools, V summary, H help, A alias, E export, D delete, N new, I import, O rename, Tab cycle, R refresh"
        } else {
            "Actions: Enter switch, C config, S SOUL, M memory, T tools, V summary, H help, A alias, E export, D delete, N new, I import, O rename, Tab cycle, R refresh"
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProfileDetailMode {
    Summary,
    Config,
    Soul,
    Memory,
    Tools,
    Help,
}

impl ProfileDetailMode {
    fn title(self) -> &'static str {
        match self {
            Self::Summary => "Summary",
            Self::Config => "Config",
            Self::Soul => "SOUL",
            Self::Memory => "Memory",
            Self::Tools => "Tools",
            Self::Help => "Help",
        }
    }

    fn next(self) -> Self {
        match self {
            Self::Summary => Self::Config,
            Self::Config => Self::Soul,
            Self::Soul => Self::Memory,
            Self::Memory => Self::Tools,
            Self::Tools => Self::Help,
            Self::Help => Self::Summary,
        }
    }

    fn previous(self) -> Self {
        match self {
            Self::Summary => Self::Help,
            Self::Config => Self::Summary,
            Self::Soul => Self::Config,
            Self::Memory => Self::Soul,
            Self::Tools => Self::Memory,
            Self::Help => Self::Tools,
        }
    }
}

impl FuzzyItem for ProfileEntry {
    fn primary(&self) -> &str {
        &self.name
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.model
    }
}

struct SelectorChrome<'a> {
    title: &'a str,
    placeholder: &'a str,
    count_label: &'a str,
    status_note: Option<&'a str>,
}

struct BrowserChrome<'a> {
    title: &'a str,
    placeholder: &'a str,
    icon: &'a str,
    icon_color: Color,
    border_color: Color,
}

struct ScrollableDetailChrome<'a> {
    title: &'a str,
    border_color: Color,
    focused: bool,
    requested_scroll: u16,
}

struct FullscreenBrowserChrome<'a> {
    query: &'a str,
    header: BrowserChrome<'a>,
    detail: ScrollableDetailChrome<'a>,
    help: Line<'static>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DetailSurface {
    ModelSelector,
    VisionModelSelector,
    ImageModelSelector,
    GatewayBrowser,
    LogBrowser,
    McpSelector,
    RemoteMcpBrowser,
    ProfileSelector,
    SkillSelector,
    RemoteSkillBrowser,
    RemotePluginBrowser,
    ToolManager,
    PluginToggle,
    ConfigSelector,
    SessionBrowser,
    SessionInspector,
    LogInspector,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct SimpleDetailState {
    surface: Option<DetailSurface>,
    scroll: u16,
    focus: SplitPaneFocus,
}

impl SimpleDetailState {
    fn scroll_for(&self, surface: DetailSurface) -> u16 {
        if self.surface == Some(surface) {
            self.scroll
        } else {
            0
        }
    }

    fn set_scroll(&mut self, surface: DetailSurface, scroll: u16) {
        if self.surface != Some(surface) {
            self.focus = SplitPaneFocus::List;
        }
        self.surface = Some(surface);
        self.scroll = scroll;
    }

    fn focus_for(&self, surface: DetailSurface) -> SplitPaneFocus {
        if self.surface == Some(surface) {
            self.focus
        } else {
            SplitPaneFocus::List
        }
    }

    fn set_focus(&mut self, surface: DetailSurface, focus: SplitPaneFocus) {
        if self.surface != Some(surface) {
            self.scroll = 0;
        }
        self.surface = Some(surface);
        self.focus = focus;
    }

    fn reset(&mut self, surface: DetailSurface) {
        self.surface = Some(surface);
        self.scroll = 0;
        self.focus = SplitPaneFocus::List;
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DetailFullscreenState {
    surface: DetailSurface,
    scroll: u16,
}

#[derive(Clone, Debug)]
struct DocumentOverlayState {
    title: String,
    subtitle: String,
    body: String,
    icon: String,
    accent: Color,
    scroll: u16,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RemoteSkillAction {
    Install,
    Update,
    Replace,
}

impl RemoteSkillAction {
    fn label(self) -> &'static str {
        match self {
            Self::Install => "install",
            Self::Update => "update",
            Self::Replace => "replace",
        }
    }
}

#[derive(Clone)]
struct RemoteSkillEntry {
    name: String,
    identifier: String,
    description: String,
    source_label: String,
    origin: String,
    trust_level: String,
    tags: Vec<String>,
    search_text: String,
    installed_name: Option<String>,
    action: RemoteSkillAction,
}

impl FuzzyItem for RemoteSkillEntry {
    fn primary(&self) -> &str {
        &self.identifier
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.source_label
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RemotePluginAction {
    Install,
    Update,
    Replace,
}

impl RemotePluginAction {
    fn label(self) -> &'static str {
        match self {
            Self::Install => "install",
            Self::Update => "update",
            Self::Replace => "replace",
        }
    }
}

#[derive(Clone)]
struct RemotePluginEntry {
    name: String,
    identifier: String,
    description: String,
    source_label: String,
    origin: String,
    trust_level: String,
    kind: String,
    tags: Vec<String>,
    requires_env: Vec<String>,
    installed_name: Option<String>,
    action: RemotePluginAction,
    search_text: String,
}

impl FuzzyItem for RemotePluginEntry {
    fn primary(&self) -> &str {
        &self.identifier
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.source_label
    }
}

struct RemoteBrowserState<T>
where
    T: Clone + FuzzyItem,
{
    selector: FuzzySelector<T>,
    notices: Vec<String>,
    last_completed_query: Option<String>,
    search_due_at: Option<Instant>,
    inflight_request_id: Option<u64>,
    next_request_id: u64,
    loading_query: Option<String>,
    action_in_flight: Option<String>,
    source_filter: Option<String>,
}

impl<T> RemoteBrowserState<T>
where
    T: Clone + FuzzyItem,
{
    fn new() -> Self {
        Self {
            selector: FuzzySelector::new(),
            notices: Vec::new(),
            last_completed_query: None,
            search_due_at: None,
            inflight_request_id: None,
            next_request_id: 0,
            loading_query: None,
            action_in_flight: None,
            source_filter: None,
        }
    }

    fn current_query(&self) -> String {
        self.selector.query.trim().to_string()
    }

    fn activate(&mut self, initial_query: Option<&str>, source_filter: Option<&str>) {
        self.selector.active = true;
        self.selector.query = initial_query.map(str::trim).unwrap_or_default().to_string();
        self.selector.selected = 0;
        self.selector.update_filter();
        self.notices.clear();
        self.last_completed_query = None;
        self.search_due_at = None;
        self.inflight_request_id = None;
        self.loading_query = None;
        self.action_in_flight = None;
        self.source_filter = source_filter
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned);
    }

    fn reset_results(&mut self) {
        self.search_due_at = None;
        self.inflight_request_id = None;
        self.loading_query = None;
        self.last_completed_query = None;
        self.notices.clear();
        self.selector.set_items(Vec::new());
    }
}

type RemoteSkillBrowser = RemoteBrowserState<RemoteSkillEntry>;
type RemotePluginBrowser = RemoteBrowserState<RemotePluginEntry>;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RemoteMcpAction {
    Install,
    View,
}

impl RemoteMcpAction {
    fn label(self) -> &'static str {
        match self {
            Self::Install => "install",
            Self::View => "view",
        }
    }
}

#[derive(Clone)]
struct RemoteMcpEntry {
    id: String,
    name: String,
    description: String,
    source_label: String,
    origin: String,
    tags: Vec<String>,
    transport: Option<String>,
    search_text: String,
    install: Option<crate::mcp_catalog::McpInstallPlan>,
}

impl RemoteMcpEntry {
    fn action(&self) -> RemoteMcpAction {
        if self.install.is_some() {
            RemoteMcpAction::Install
        } else {
            RemoteMcpAction::View
        }
    }
}

impl FuzzyItem for RemoteMcpEntry {
    fn primary(&self) -> &str {
        &self.id
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.source_label
    }
}

type RemoteMcpBrowser = RemoteBrowserState<RemoteMcpEntry>;

/// A single MCP preset entry for the MCP browser overlay.
#[derive(Clone)]
struct McpPresetEntry {
    id: String,
    kind: McpEntryKind,
    install_preset_id: Option<String>,
    enabled: bool,
    title: String,
    source: String,
    action_label: String,
    detail: String,
    detail_view: String,
    search_text: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum McpEntryKind {
    CatalogEntry,
    ConfiguredServer,
}

impl FuzzyItem for McpPresetEntry {
    fn primary(&self) -> &str {
        &self.title
    }
    fn secondary(&self) -> &str {
        &self.search_text
    }
    fn tag(&self) -> &str {
        &self.source
    }
}

impl McpPresetEntry {
    fn is_configured_server(&self) -> bool {
        self.kind == McpEntryKind::ConfiguredServer
    }

    fn display_title(&self) -> String {
        if self.is_configured_server() {
            format!("[{}] {}", if self.enabled { "x" } else { " " }, self.title)
        } else {
            self.title.clone()
        }
    }

    fn default_command(&self) -> String {
        match self.kind {
            McpEntryKind::CatalogEntry => {
                if self.action_label == "install" {
                    self.install_command()
                        .unwrap_or_else(|| self.view_command())
                } else {
                    self.view_command()
                }
            }
            McpEntryKind::ConfiguredServer => {
                if self.enabled {
                    self.test_command()
                } else {
                    self.enable_command()
                }
            }
        }
    }

    fn install_command(&self) -> Option<String> {
        self.install_preset_id
            .as_deref()
            .map(|preset_id| format!("install {preset_id}"))
    }

    fn test_command(&self) -> String {
        format!("test {}", self.id)
    }

    fn doctor_command(&self) -> String {
        format!("doctor {}", self.id)
    }

    fn enable_command(&self) -> String {
        format!("enable {}", self.id)
    }

    fn disable_command(&self) -> String {
        format!("disable {}", self.id)
    }

    fn toggle_command(&self) -> Option<String> {
        self.is_configured_server().then(|| {
            if self.enabled {
                self.disable_command()
            } else {
                self.enable_command()
            }
        })
    }

    fn detail_actions_line(&self) -> &'static str {
        if !self.is_configured_server() {
            return "Actions: Enter default, I install when available, V view, R refresh catalog";
        }
        if self.enabled {
            "Actions: Space disable, Enter test, C doctor, V view, D remove, R refresh catalog"
        } else {
            "Actions: Space enable, Enter enable, C doctor, V view, D remove, R refresh catalog"
        }
    }

    fn view_command(&self) -> String {
        format!("view {}", self.id)
    }

    fn remove_command(&self) -> Option<String> {
        (self.kind == McpEntryKind::ConfiguredServer).then(|| format!("remove {}", self.id))
    }
}

fn mcp_catalog_source_label(entry: &crate::mcp_catalog::OfficialCatalogEntry) -> &'static str {
    if entry.tags.iter().any(|tag| tag == "archived") {
        return "official-arch";
    }
    if entry.tags.iter().any(|tag| tag == "integration") {
        return "official-app";
    }
    "official-ref"
}

fn format_mcp_transport(server: &edgecrab_tools::tools::mcp_client::ConfiguredMcpServer) -> String {
    mcp_support::transport_summary(server)
}

fn mcp_enabled_label(enabled: bool) -> &'static str {
    if enabled { "enabled" } else { "disabled" }
}

fn build_configured_mcp_entry(
    server: &edgecrab_tools::tools::mcp_client::ConfiguredMcpServer,
) -> McpPresetEntry {
    let transport = format_mcp_transport(server);
    let state_label = mcp_enabled_label(server.enabled);
    let mut detail = format!("{transport} | {state_label}");
    let detail_view = mcp_support::render_configured_server_detail(server);
    let auth = mcp_support::auth_summary(server);
    if auth != "none" {
        detail.push_str(&format!(" | {auth}"));
    }
    if let Some(path) = &server.cwd {
        detail.push_str(&format!(" | cwd={}", path.display()));
    }
    let search_text = format!(
        "{} {} {} {} {} {} {}",
        server.name,
        transport,
        server
            .cwd
            .as_ref()
            .map(|path| path.display().to_string())
            .unwrap_or_default(),
        server.include.join(" "),
        server.exclude.join(" "),
        if server.token_from_store {
            "token store"
        } else if server.token_from_config {
            "config token"
        } else {
            ""
        },
        detail
    );
    McpPresetEntry {
        id: server.name.clone(),
        kind: McpEntryKind::ConfiguredServer,
        install_preset_id: None,
        enabled: server.enabled,
        title: server.name.clone(),
        source: "configured".into(),
        action_label: if server.enabled { "test" } else { "enable" }.into(),
        detail,
        detail_view: detail_view.clone(),
        search_text: format!("{search_text} {}", detail_view.replace('\n', " ")),
    }
}

fn build_catalog_mcp_entry(
    entry: &crate::mcp_catalog::OfficialCatalogEntry,
    configured_names: &std::collections::HashSet<&str>,
) -> McpPresetEntry {
    let preset = entry
        .installable_preset_id
        .as_deref()
        .and_then(crate::mcp_catalog::find_preset);
    let install_state = if entry
        .installable_preset_id
        .as_deref()
        .is_some_and(|preset_id| configured_names.contains(preset_id))
    {
        "already-installed"
    } else if entry.installable_preset_id.is_some() {
        "ready-to-install"
    } else {
        "catalog-only"
    };
    let action_label = if install_state == "ready-to-install" {
        "install".to_string()
    } else {
        "view".to_string()
    };
    let detail = match preset {
        Some(preset) if !preset.required_env.is_empty() => format!(
            "{} | {} | pkg={} | env={} | {}",
            entry.description,
            install_state,
            preset.package_name,
            preset.required_env.join(","),
            entry.source_url
        ),
        Some(preset) => format!(
            "{} | {} | pkg={} | {}",
            entry.description, install_state, preset.package_name, entry.source_url
        ),
        None => format!(
            "{} | {} | {}",
            entry.description, install_state, entry.source_url
        ),
    };
    let mut detail_view = crate::mcp_catalog::render_official_catalog_entry(entry);
    if install_state == "already-installed" {
        detail_view.push_str("\nStatus:  already installed in the local MCP configuration");
    } else if install_state == "ready-to-install" {
        detail_view.push_str("\nStatus:  ready to install into the local MCP configuration");
    } else {
        detail_view.push_str("\nStatus:  catalog only, inspect documentation before configuring");
    }
    let search_text = format!(
        "{} {} {} {} {} {} {} {} {}",
        entry.id,
        entry.display_name,
        entry.description,
        entry.source_url,
        entry.homepage,
        entry.tags.join(" "),
        preset.map(|preset| preset.package_name).unwrap_or_default(),
        preset.map(|preset| preset.command).unwrap_or_default(),
        preset
            .map(|preset| preset.required_env.join(" "))
            .unwrap_or_default()
    );
    McpPresetEntry {
        id: entry.id.clone(),
        kind: McpEntryKind::CatalogEntry,
        install_preset_id: entry.installable_preset_id.clone(),
        enabled: true,
        title: format!("{}  {}", entry.id, entry.display_name),
        source: mcp_catalog_source_label(entry).into(),
        action_label,
        detail,
        detail_view: detail_view.clone(),
        search_text: format!("{search_text} {}", detail_view.replace('\n', " ")),
    }
}

fn build_mcp_selector_entries_from(
    configured: &[edgecrab_tools::tools::mcp_client::ConfiguredMcpServer],
    official_entries: &[crate::mcp_catalog::OfficialCatalogEntry],
) -> Vec<McpPresetEntry> {
    let configured_names: std::collections::HashSet<&str> = configured
        .iter()
        .map(|server| server.name.as_str())
        .collect();

    let mut configured_entries: Vec<McpPresetEntry> =
        configured.iter().map(build_configured_mcp_entry).collect();
    configured_entries.sort_by(|left, right| left.title.cmp(&right.title));

    let mut catalog_entries: Vec<McpPresetEntry> = official_entries
        .iter()
        .map(|entry| build_catalog_mcp_entry(entry, &configured_names))
        .collect();
    catalog_entries.sort_by(|left, right| {
        left.source
            .cmp(&right.source)
            .then_with(|| left.title.cmp(&right.title))
    });

    let mut entries = configured_entries;
    entries.extend(catalog_entries);

    entries
}

/// A single entry for the session browser overlay.
/// Wraps rich session metadata with list/detail strings so that fuzzy filtering
/// and rendering stay allocation-light while typing.
#[derive(Clone)]
struct SessionBrowserEntry {
    id: String,
    display: String,
    source: String,
    model: String,
    message_count: i64,
    started_label: String,
    last_active_label: String,
    preview: String,
    detail: String,
    detail_view: String,
    search_text: String,
    matched_role: Option<String>,
    matched_snippet: Option<String>,
}

impl SessionBrowserEntry {
    fn from_summary(summary: &edgecrab_state::SessionRichSummary) -> Self {
        Self::from_parts(summary, None, None)
    }

    fn from_search_hit(hit: &edgecrab_state::SessionSearchHit) -> Self {
        Self::from_parts(&hit.session, Some(&hit.role), Some(&hit.snippet))
    }

    fn from_parts(
        summary: &edgecrab_state::SessionRichSummary,
        matched_role: Option<&str>,
        matched_snippet: Option<&str>,
    ) -> Self {
        let display = summary
            .title
            .as_deref()
            .filter(|t| !t.is_empty())
            .unwrap_or(&summary.id[..summary.id.len().min(12)])
            .to_string();
        let model = summary.model.as_deref().unwrap_or("unknown").to_string();
        let preview = session_excerpt(&summary.preview, 88);
        let started_label = format_session_timestamp(summary.started_at);
        let last_active_label = format_session_timestamp(summary.last_active);
        let matched_snippet = matched_snippet
            .map(strip_search_markup)
            .filter(|snippet| !snippet.is_empty());
        let detail = if let Some(role) = matched_role {
            format!(
                "{} msgs | {} | {} match",
                summary.message_count, model, role
            )
        } else {
            format!("{} msgs | {}", summary.message_count, model)
        };

        let mut detail_sections = vec![
            format!("Session:   {}", summary.id),
            format!("Source:    {}", summary.source),
            format!("Model:     {}", model),
            format!("Messages:  {}", summary.message_count),
            format!("Started:   {started_label}"),
            format!("Last seen: {last_active_label}"),
        ];
        if !preview.is_empty() {
            detail_sections.push(format!("Preview:   {preview}"));
        }
        if let (Some(role), Some(snippet)) = (matched_role, matched_snippet.as_deref()) {
            detail_sections.push(format!("Match:     {role} -> {snippet}"));
        }
        detail_sections.push(String::new());
        detail_sections.push("Actions: Enter inspect | R resume | D delete | Esc close".into());

        let mut search_parts = vec![
            summary.id.clone(),
            display.clone(),
            summary.source.clone(),
            model.clone(),
            preview.clone(),
        ];
        if let Some(snippet) = matched_snippet.as_deref() {
            search_parts.push(snippet.to_string());
        }
        if let Some(role) = matched_role {
            search_parts.push(role.to_string());
        }

        Self {
            id: summary.id.clone(),
            display,
            source: summary.source.clone(),
            model,
            message_count: summary.message_count,
            started_label,
            last_active_label,
            preview,
            detail,
            detail_view: detail_sections.join("\n"),
            search_text: search_parts.join(" "),
            matched_role: matched_role.map(str::to_string),
            matched_snippet,
        }
    }
}

impl FuzzyItem for SessionBrowserEntry {
    fn primary(&self) -> &str {
        &self.display
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.source
    }
}

#[derive(Clone)]
struct SessionMessageEntry {
    index: usize,
    role_label: String,
    headline: String,
    preview: String,
    meta: String,
    message: edgecrab_types::Message,
    search_text: String,
}

impl SessionMessageEntry {
    fn from_message(index: usize, message: &edgecrab_types::Message) -> Self {
        let role_label = message.role.as_str().to_string();
        let content = message.text_content();
        let preview = message_preview(message);
        let mut meta_bits = Vec::new();
        if let Some(name) = message.name.as_deref().filter(|name| !name.is_empty()) {
            meta_bits.push(format!("tool={name}"));
        }
        if let Some(tool_call_id) = message
            .tool_call_id
            .as_deref()
            .filter(|tool_call_id| !tool_call_id.is_empty())
        {
            meta_bits.push(format!("call={}", unicode_trunc(tool_call_id, 16)));
        }
        if let Some(tool_calls) = message
            .tool_calls
            .as_ref()
            .filter(|tool_calls| !tool_calls.is_empty())
        {
            meta_bits.push(format!("calls={}", tool_calls.len()));
        }
        if message
            .reasoning
            .as_deref()
            .is_some_and(|reasoning| !reasoning.trim().is_empty())
        {
            meta_bits.push("reasoning".into());
        }
        if let Some(reason) = message
            .finish_reason
            .as_deref()
            .filter(|reason| !reason.is_empty())
        {
            meta_bits.push(format!("finish={reason}"));
        }
        let meta = if meta_bits.is_empty() {
            "content".into()
        } else {
            meta_bits.join(" | ")
        };
        let headline = format!("#{:<3} {}", index + 1, role_label);

        let mut search_parts = vec![headline.clone(), preview.clone(), meta.clone()];
        if !content.trim().is_empty() {
            search_parts.push(content);
        }
        if let Some(reasoning) = message.reasoning.as_deref() {
            search_parts.push(reasoning.to_string());
        }
        if let Some(tool_calls) = message.tool_calls.as_ref() {
            for call in tool_calls {
                search_parts.push(call.id.clone());
                search_parts.push(call.function.name.clone());
            }
        }
        if let Some(tool_name) = message.name.as_deref() {
            search_parts.push(tool_name.to_string());
        }

        Self {
            index,
            role_label,
            headline,
            preview,
            meta,
            message: message.clone(),
            search_text: search_parts.join(" "),
        }
    }

    fn browser_match_score(
        &self,
        matched_role: Option<&str>,
        matched_snippet: Option<&str>,
    ) -> usize {
        let mut score = 0usize;

        if matched_role.is_some_and(|role| role.eq_ignore_ascii_case(&self.role_label)) {
            score += 100;
        }

        let haystack = normalize_search_haystack(&self.search_text);
        if let Some(snippet) = matched_snippet {
            let snippet = normalize_search_haystack(snippet);
            if !snippet.is_empty() {
                if haystack.contains(&snippet) {
                    score += 1_000;
                }

                let terms = snippet
                    .split_whitespace()
                    .filter(|term| term.len() > 1)
                    .collect::<Vec<_>>();
                if !terms.is_empty() {
                    let matched_terms = terms
                        .iter()
                        .filter(|term| haystack.contains(**term))
                        .count();
                    score += matched_terms * 25;
                    if matched_terms == terms.len() {
                        score += 250;
                    }
                }
            }
        }

        score
    }
}

impl FuzzyItem for SessionMessageEntry {
    fn primary(&self) -> &str {
        &self.headline
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.role_label
    }
}

#[derive(Clone)]
struct SessionInspectorMeta {
    id: String,
    title: String,
    source: String,
    model: String,
    started_label: String,
    last_active_label: String,
    message_count: i64,
    preview: String,
    matched_role: Option<String>,
    matched_snippet: Option<String>,
    is_live: bool,
    debug_lines: Vec<String>,
}

impl SessionInspectorMeta {
    fn from_browser_entry(entry: &SessionBrowserEntry) -> Self {
        Self {
            id: entry.id.clone(),
            title: entry.display.clone(),
            source: entry.source.clone(),
            model: entry.model.clone(),
            started_label: entry.started_label.clone(),
            last_active_label: entry.last_active_label.clone(),
            message_count: entry.message_count,
            preview: entry.preview.clone(),
            matched_role: entry.matched_role.clone(),
            matched_snippet: entry.matched_snippet.clone(),
            is_live: false,
            debug_lines: Vec::new(),
        }
    }
}

fn normalize_search_haystack(text: &str) -> String {
    let mut normalized = String::with_capacity(text.len());
    let mut last_was_space = true;

    for ch in text.chars() {
        if ch.is_alphanumeric() {
            normalized.extend(ch.to_lowercase());
            last_was_space = false;
        } else if !last_was_space {
            normalized.push(' ');
            last_was_space = true;
        }
    }

    normalized.trim().to_string()
}

struct SessionInspector {
    selector: FuzzySelector<SessionMessageEntry>,
    session: Option<SessionInspectorMeta>,
    pane: DetailPaneState,
    return_to_browser: bool,
}

impl SessionInspector {
    fn new() -> Self {
        Self {
            selector: FuzzySelector::new(),
            session: None,
            pane: DetailPaneState::default(),
            return_to_browser: false,
        }
    }

    fn active(&self) -> bool {
        self.selector.active
    }

    fn close(&mut self) {
        self.selector.active = false;
        self.session = None;
        self.pane.reset();
        self.return_to_browser = false;
    }
}

#[derive(Clone)]
struct LogBrowserEntry {
    name: String,
    path: std::path::PathBuf,
    kind: String,
    size_label: String,
    modified_label: String,
    preview: String,
    detail: String,
    detail_view: String,
    search_text: String,
}

impl FuzzyItem for LogBrowserEntry {
    fn primary(&self) -> &str {
        &self.name
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.kind
    }
}

#[derive(Clone)]
struct LogLineEntry {
    level_label: String,
    timestamp: String,
    headline: String,
    summary: String,
    detail: String,
    search_text: String,
}

impl FuzzyItem for LogLineEntry {
    fn primary(&self) -> &str {
        &self.headline
    }

    fn secondary(&self) -> &str {
        &self.search_text
    }

    fn tag(&self) -> &str {
        &self.level_label
    }
}

#[derive(Clone)]
struct LogInspectorMeta {
    name: String,
    path: std::path::PathBuf,
    kind: String,
    size_label: String,
    modified_label: String,
    preview: String,
}

struct LogInspector {
    selector: FuzzySelector<LogLineEntry>,
    file: Option<LogInspectorMeta>,
    pane: DetailPaneState,
    return_to_browser: bool,
}

impl LogInspector {
    fn new() -> Self {
        Self {
            selector: FuzzySelector::new(),
            file: None,
            pane: DetailPaneState::default(),
            return_to_browser: false,
        }
    }

    fn active(&self) -> bool {
        self.selector.active
    }

    fn close(&mut self) {
        self.selector.active = false;
        self.file = None;
        self.pane.reset();
        self.return_to_browser = false;
    }
}

struct LogFollowState {
    enabled: bool,
    last_refresh: Instant,
}

impl LogFollowState {
    fn new() -> Self {
        Self {
            enabled: true,
            last_refresh: Instant::now(),
        }
    }

    fn badge(&self) -> &'static str {
        if self.enabled {
            "follow on"
        } else {
            "follow off"
        }
    }

    fn touch(&mut self, now: Instant) {
        self.last_refresh = now;
    }

    fn due(&self, now: Instant) -> bool {
        self.enabled && now.duration_since(self.last_refresh) >= LOG_FOLLOW_REFRESH_INTERVAL
    }
}

fn log_kind_for_name(name: &str) -> &'static str {
    if name.ends_with(".jsonl") {
        "json"
    } else if name.contains("error") {
        "errors"
    } else if name.ends_with(".log.1")
        || name.ends_with(".log.2")
        || name.ends_with(".log.3")
        || name.ends_with(".log.4")
        || name.ends_with(".log.5")
    {
        "backup"
    } else {
        "text"
    }
}

fn format_log_timestamp_label(modified: Option<std::time::SystemTime>) -> String {
    let Some(t) = modified else {
        return "unknown".into();
    };
    // Primary: human-relative ("3 min ago"); secondary: absolute date appended for
    // timestamps older than 24 h so the user can still orient by calendar date.
    let relative = format_relative_time(t);
    let elapsed_secs = std::time::SystemTime::now()
        .duration_since(t)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    if elapsed_secs >= 86400 {
        // Append the calendar date for older entries.
        let abs = t
            .duration_since(std::time::UNIX_EPOCH)
            .ok()
            .and_then(|d| chrono::Local.timestamp_opt(d.as_secs() as i64, 0).single())
            .map(|dt| dt.format("%Y-%m-%d").to_string())
            .unwrap_or_default();
        if abs.is_empty() {
            relative
        } else {
            format!("{relative} ({abs})")
        }
    } else {
        relative
    }
}

fn default_log_browser_detail_lines() -> Vec<Line<'static>> {
    vec![
        Line::from(Span::styled(
            "Log Browser",
            Style::default()
                .fg(Color::Rgb(255, 196, 120))
                .add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from(
            "Browse the local log archive on the left and inspect the latest tail on the right.",
        ),
        Line::from(
            "Press Enter on a file to open the entry inspector, where each log event gets its own detail pane.",
        ),
        Line::from(
            "Use 1-5 to save and apply the runtime level: 1 error, 2 warn, 3 info, 4 debug, 5 trace.",
        ),
    ]
}

fn log_boundary_line(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed.starts_with('{')
        || trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit())
            && [" TRACE ", " DEBUG ", " INFO ", " WARN ", " ERROR "]
                .iter()
                .any(|needle| trimmed.contains(needle))
}

fn summarize_json_log_line(value: &serde_json::Value) -> (String, String, String) {
    let timestamp = value
        .get("timestamp")
        .and_then(serde_json::Value::as_str)
        .or_else(|| value.get("time").and_then(serde_json::Value::as_str))
        .unwrap_or("structured")
        .to_string();
    let level = value
        .get("level")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("json")
        .to_ascii_uppercase();
    let summary = value
        .get("fields")
        .and_then(|fields| fields.get("message"))
        .and_then(serde_json::Value::as_str)
        .or_else(|| value.get("message").and_then(serde_json::Value::as_str))
        .unwrap_or("structured log entry")
        .to_string();
    (timestamp, level, summary)
}

fn parse_log_entry_chunks(content: &str) -> Vec<String> {
    let mut entries = Vec::new();
    let mut current = String::new();

    for line in content.lines() {
        if current.is_empty() {
            current.push_str(line);
            continue;
        }

        if log_boundary_line(line) {
            entries.push(current);
            current = line.to_string();
        } else {
            current.push('\n');
            current.push_str(line);
        }
    }

    if !current.is_empty() {
        entries.push(current);
    }

    entries
}

fn extract_text_log_level(line: &str) -> String {
    for level in ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"] {
        if line.contains(level) {
            return level.to_string();
        }
    }
    "LINE".into()
}

fn extract_text_log_timestamp(line: &str) -> String {
    line.split_whitespace()
        .take(2)
        .collect::<Vec<_>>()
        .join(" ")
        .trim()
        .to_string()
}

fn build_log_line_entries(content: &str) -> Vec<LogLineEntry> {
    parse_log_entry_chunks(content)
        .into_iter()
        .enumerate()
        .map(|(index, detail)| {
            let first_line = detail.lines().next().unwrap_or_default().trim();
            let (timestamp, level_label, summary) = if first_line.starts_with('{') {
                match serde_json::from_str::<serde_json::Value>(first_line) {
                    Ok(value) => summarize_json_log_line(&value),
                    Err(_) => (
                        "structured".into(),
                        "JSON".into(),
                        unicode_trunc(first_line, 96),
                    ),
                }
            } else {
                (
                    extract_text_log_timestamp(first_line),
                    extract_text_log_level(first_line),
                    unicode_trunc(first_line, 96),
                )
            };
            let headline = format!(
                "#{:04} {} {}",
                index + 1,
                level_label,
                unicode_trunc(&summary, 72)
            );
            let search_text = format!("{headline} {timestamp} {summary} {detail}");
            LogLineEntry {
                level_label,
                timestamp,
                headline,
                summary,
                detail,
                search_text,
            }
        })
        .collect()
}

fn strip_search_markup(snippet: &str) -> String {
    snippet
        .replace("<b>", "")
        .replace("</b>", "")
        .replace(['\n', '\r'], " ")
        .trim()
        .to_string()
}

fn session_excerpt(text: &str, max_cols: usize) -> String {
    unicode_trunc(text.replace(['\n', '\r'], " ").trim(), max_cols)
}

fn format_session_timestamp(epoch_secs: f64) -> String {
    if !epoch_secs.is_finite() || epoch_secs <= 0.0 {
        return "unknown".into();
    }
    match chrono::Local.timestamp_opt(epoch_secs as i64, 0).single() {
        Some(timestamp) => timestamp.format("%Y-%m-%d %H:%M").to_string(),
        None => {
            let d = time_secs_to_date(epoch_secs as i64);
            format!("{:04}-{:02}-{:02}", d.0, d.1, d.2)
        }
    }
}

fn message_preview(message: &edgecrab_types::Message) -> String {
    let text = message.text_content();
    if !text.trim().is_empty() {
        return session_excerpt(&text, 96);
    }
    if let Some(tool_calls) = message
        .tool_calls
        .as_ref()
        .filter(|tool_calls| !tool_calls.is_empty())
    {
        let names = tool_calls
            .iter()
            .take(3)
            .map(|call| call.function.name.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        return format!("requested tools: {names}");
    }
    if let Some(name) = message.name.as_deref().filter(|name| !name.is_empty()) {
        return format!("tool output from {name}");
    }
    "(empty message)".into()
}

// ── Skin browser entry ────────────────────────────────────────────────────

#[derive(Clone)]
struct SkinEntry {
    /// Skin name (file stem, e.g. "dracula")
    name: String,
    /// Short description shown in the secondary column
    desc: String,
    /// Whether this is the currently active skin
    is_active: bool,
}

impl FuzzyItem for SkinEntry {
    fn primary(&self) -> &str {
        &self.name
    }
    fn secondary(&self) -> &str {
        &self.desc
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConfigAction {
    ShowSummary,
    ShowPaths,
    ShowWorktree,
    OpenTools,
    OpenModel,
    OpenCheapModel,
    ToggleMoa,
    OpenVisionModel,
    OpenImageModel,
    OpenMoaAggregator,
    OpenMoaReferences,
    AddMoaExpert,
    RemoveMoaExpert,
    ToggleStreaming,
    ToggleReasoning,
    ToggleStatusBar,
    OpenLogs,
    OpenSkins,
    ShowVoice,
    OpenGatewayBrowser,
    ShowGatewayHomes,
    ShowUpdateStatus,
}

#[derive(Clone)]
struct ConfigEntry {
    title: String,
    detail: String,
    tag: String,
    action: ConfigAction,
}

impl FuzzyItem for ConfigEntry {
    fn primary(&self) -> &str {
        &self.title
    }

    fn secondary(&self) -> &str {
        &self.detail
    }

    fn tag(&self) -> &str {
        &self.tag
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToolManagerScope {
    All,
    Toolsets,
    Tools,
}

impl ToolManagerScope {
    fn from_mode(mode: ToolManagerMode) -> Self {
        match mode {
            ToolManagerMode::All => Self::All,
            ToolManagerMode::Toolsets => Self::Toolsets,
        }
    }

    fn next(self) -> Self {
        match self {
            Self::All => Self::Toolsets,
            Self::Toolsets => Self::Tools,
            Self::Tools => Self::All,
        }
    }

    fn previous(self) -> Self {
        match self {
            Self::All => Self::Tools,
            Self::Toolsets => Self::All,
            Self::Tools => Self::Toolsets,
        }
    }

    fn title(self) -> &'static str {
        match self {
            Self::All => "all",
            Self::Toolsets => "toolsets",
            Self::Tools => "tools",
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToolManagerItemKind {
    Toolset,
    Tool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToolManagerCheckState {
    On,
    Off,
    Mixed,
}

impl ToolManagerCheckState {
    fn glyph(self) -> &'static str {
        match self {
            Self::On => "[x]",
            Self::Off => "[ ]",
            Self::Mixed => "[-]",
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToolPolicySource {
    Default,
    ExplicitEnable,
    ExplicitDisable,
}

#[derive(Clone)]
struct ToolManagerEntry {
    kind: ToolManagerItemKind,
    name: String,
    toolset: String,
    description: String,
    detail: String,
    tag: String,
    check_state: ToolManagerCheckState,
    policy_source: ToolPolicySource,
    exposed: bool,
    startup_available: bool,
    check_allowed: bool,
    dynamic: bool,
    emoji: String,
    aliases: Vec<String>,
    selected_tools: usize,
    total_tools: usize,
    exposed_tools: usize,
}

impl FuzzyItem for ToolManagerEntry {
    fn primary(&self) -> &str {
        &self.name
    }

    fn secondary(&self) -> &str {
        &self.detail
    }

    fn tag(&self) -> &str {
        &self.tag
    }
}

/// Convert a Unix-epoch timestamp (seconds) to (year, month, day).
/// Simple Gregorian calendar implementation without external crates.
fn time_secs_to_date(secs: i64) -> (i32, u32, u32) {
    // Days since 1970-01-01
    let days = secs / 86400;
    // Use proleptic Gregorian algorithm
    let z = days + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = (z - era * 146097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y as i32, m, d)
}

/// Main TUI application state.
pub struct App {
    /// tui-textarea multi-line input widget
    textarea: TextArea<'static>,
    /// Explicit editor mode for the input widget.
    editor_mode: InputEditorMode,
    /// Pending Vim-style operator / motion prefix in compose normal mode.
    vim_pending: Option<VimPending>,
    /// Whether the terminal reports disambiguated modifier keys (kitty/CSI-u).
    keyboard_enhancement_enabled: bool,
    /// Output lines (scrollable)
    output: Vec<OutputLine>,
    /// Scroll offset from bottom
    scroll_offset: u16,
    /// Current theme
    theme: Theme,
    /// Slash command registry
    commands: CommandRegistry,
    /// Whether the app should exit
    should_exit: bool,
    /// Status bar info
    model_name: String,
    /// Context window size for the active model (tokens). Used to render a
    /// `12.4k / 200k (9%)` watermark in the status bar so the user can see
    /// context pressure at a glance. `None` when the model is not in the catalog.
    context_window: Option<u64>,
    /// Current prompt-pressure tokens shown in the status bar.
    ///
    /// This tracks the latest prompt sent to the model rather than cumulative
    /// session spend, so the indicator reflects current context pressure.
    total_tokens: u64,
    session_cost: f64,
    /// Agent for LLM dispatch
    agent: Option<Arc<Agent>>,
    /// Tokio runtime handle for spawning async tasks from the sync TUI loop
    rt_handle: tokio::runtime::Handle,
    /// Channel receiver for agent responses
    response_rx: mpsc::UnboundedReceiver<AgentResponse>,
    /// Channel sender (cloned into background tasks)
    response_tx: mpsc::UnboundedSender<AgentResponse>,
    /// Monotonic counter for isolated `/background` sessions in this TUI run.
    background_task_seq: u64,
    /// Monotonic sequence for live progress updates rendered in the UI.
    progress_seq: u64,
    /// Active isolated `/background` sessions keyed by task ID.
    background_tasks_active: std::collections::HashMap<String, BackgroundTaskStatus>,
    /// Active foreground tool calls keyed by tool_call_id for parallel status summaries.
    active_tools: std::collections::HashMap<String, ActiveToolStatus>,
    /// Active delegated child tasks for the foreground agent turn.
    active_subagents: std::collections::HashMap<usize, ActiveSubagentStatus>,
    /// Channel for cron job completion notifications from the background ticker.
    /// The background cron task sends formatted strings; the TUI drains them in
    /// `check_responses()` and displays them as assistant-style output lines.
    cron_rx: mpsc::UnboundedReceiver<String>,
    cron_tx: mpsc::UnboundedSender<String>,
    /// Whether the agent is currently processing a prompt
    is_processing: bool,
    /// Abort handle for the live foreground request pump.
    ///
    /// WHY explicit abort in addition to `agent.interrupt()`: some terminal and
    /// provider paths can leave the outer stream-forwarding task waiting for the
    /// next event even after the agent cancel token is set. Aborting the pump
    /// lets the TUI leave the "waiting for first token" state immediately.
    active_request_abort: Option<tokio::task::AbortHandle>,
    /// Index of the output line currently being streamed into (-1 = none)
    streaming_line: Option<usize>,
    /// Index of the current reasoning / think-mode block for this turn.
    reasoning_line: Option<usize>,
    /// Whether extended thinking should be shown in the output pane.
    show_reasoning: bool,
    /// Whether API-level streaming is enabled for future turns.
    streaming_enabled: bool,
    /// Whether assistant tokens should be rendered incrementally in the output
    /// pane, or buffered and flushed only at turn/tool boundaries.
    live_token_display_enabled: bool,
    /// Persisted tool-progress display policy.
    tool_progress_mode: ToolProgressMode,
    /// Whether the status bar is visible.
    show_status_bar: bool,
    /// Whether dangerous command approvals are bypassed for the current session.
    yolo_enabled: bool,
    /// Queued prompts to run after the current one completes
    prompt_queue: Vec<String>,
    /// Display state machine (spinner animation)
    display_state: DisplayState,
    /// Tab-completion overlay
    completion: CompletionState,
    /// Input history ring buffer
    input_history: Vec<String>,
    /// Lazy-load persisted history on first actual history-dependent action.
    history_loaded: bool,
    /// Current position in history (history.len() = "new input")
    history_pos: usize,
    /// Saved input before history navigation started
    history_stash: String,
    /// Last response completion time (for latency display)
    last_response_time: Option<Instant>,
    /// Time To First Token for the most recent turn (seconds). Set when the
    /// first streaming token arrives after an AwaitingFirstToken phase. Shown
    /// as a calibration hint after each turn completes.
    last_ttfb_secs: Option<f32>,
    /// Most recent authoritative harness outcome for the active session.
    last_run_outcome: Option<edgecrab_types::RunOutcome>,
    /// All command names for completion (cached at startup)
    all_command_names: Vec<String>,
    /// Command name → description (for completion overlay)
    command_descriptions: std::collections::HashMap<String, String>,
    /// Model selector overlay (activated by `/model` with no args)
    model_selector: FuzzySelector<ModelEntry>,
    /// True once the static model catalog has been seeded into the shared selector.
    model_selector_seeded: bool,
    /// Which model-setting flow is currently using the shared selector.
    model_selector_target: ModelSelectorTarget,
    /// True while background live model discovery is refreshing the selector.
    model_selector_refresh_in_flight: bool,
    /// Vision-model selector overlay (activated by `/vision_model`)
    vision_model_selector: FuzzySelector<ModelEntry>,
    /// Image-model selector overlay (activated by `/image_model`)
    image_model_selector: FuzzySelector<ModelEntry>,
    /// MoA expert selector overlay (activated by `/moa experts`, `/moa add`, or `/moa remove`)
    moa_reference_selector: FuzzySelector<ModelEntry>,
    /// Currently selected default reference models in the MoA selector.
    moa_reference_selected: BTreeSet<String>,
    /// Active interaction mode for the MoA expert selector.
    moa_reference_selector_mode: MoaReferenceSelectorMode,
    /// Official MCP preset selector overlay (activated by `/mcp` with no args).
    mcp_selector: FuzzySelector<McpPresetEntry>,
    /// Remote MCP browser overlay (activated by `/mcp search`).
    remote_mcp_browser: RemoteMcpBrowser,
    /// Profile browser overlay (activated by `/profiles` with no args)
    profile_selector: FuzzySelector<ProfileEntry>,
    /// Active detail tab inside the profile browser overlay.
    profile_detail_mode: ProfileDetailMode,
    /// Skill browser overlay (activated by `/skills` with no args)
    skill_selector: FuzzySelector<SkillEntry>,
    /// Tool manager overlay (activated by `/tools` or `/toolsets`)
    tool_manager: FuzzySelector<ToolManagerEntry>,
    /// Active tool-manager scope tab.
    tool_manager_scope: ToolManagerScope,
    /// Last non-error status note shown in the tool manager footer.
    tool_manager_status_note: Option<String>,
    /// Plugin toggle overlay (activated by `/plugins toggle`)
    plugin_toggle: FuzzySelector<PluginToggleEntry>,
    /// Current plugin toggle scope.
    plugin_toggle_scope: PluginScope,
    /// Last non-error status note shown in the plugin toggle footer.
    plugin_toggle_status_note: Option<String>,
    /// Remote plugin browser overlay (activated by `/plugins search` or `/plugins browse`)
    remote_plugin_browser: RemotePluginBrowser,
    /// Remote skill browser overlay (activated by `/skills search` or `/skills hub`)
    remote_skill_browser: RemoteSkillBrowser,
    /// Session browser overlay (activated by F5 or `/session` with no args)
    session_browser: FuzzySelector<SessionBrowserEntry>,
    /// Last non-error status note shown in the session browser footer.
    session_browser_status_note: Option<String>,
    /// Focus and scroll state for the session browser detail pane.
    session_browser_pane: DetailPaneState,
    /// Log browser overlay (activated by `/log`)
    log_browser: FuzzySelector<LogBrowserEntry>,
    /// Last non-error status note shown in the log browser footer.
    log_browser_status_note: Option<String>,
    /// Focus and scroll state for the log browser detail pane.
    log_browser_pane: DetailPaneState,
    /// Shared fullscreen detail mode for split-pane selector browsers.
    detail_fullscreen: Option<DetailFullscreenState>,
    /// Scroll position for split-view overlays that do not expose a dedicated pane focus model.
    simple_detail_state: SimpleDetailState,
    /// Shared read-only report/document overlay for slash command inspection flows.
    document_overlay: Option<DocumentOverlayState>,
    /// Drill-down inspector for a single saved session.
    session_inspector: SessionInspector,
    /// Drill-down inspector for a single log file tail.
    log_inspector: LogInspector,
    /// Shared live-follow state for log browser and inspector overlays.
    log_follow: LogFollowState,
    /// Config center overlay (activated by `/config`)
    config_selector: FuzzySelector<ConfigEntry>,
    /// Gateway platform browser overlay (activated by `/platforms`)
    gateway_browser: FuzzySelector<GatewayPlatformEntry>,
    /// Focus and scroll state for the gateway browser detail pane.
    gateway_browser_pane: DetailPaneState,
    /// Gateway diagnostics overlay (activated by `/gateway diagnose`)
    diagnose_panel: DiagnosePanelState,
    /// Skin browser overlay (activated by `/skin list`)
    skin_browser: FuzzySelector<SkinEntry>,
    /// Verbose / tool-progress picker overlay (activated by `/verbose` with no args)
    verbose_selector_active: bool,
    /// Which row is highlighted in the verbose picker (0=Off,1=New,2=All,3=Verbose)
    verbose_selector_cursor: usize,
    /// Reasoning picker overlay (activated by `/reasoning` with no args)
    reasoning_selector_active: bool,
    /// Which row is highlighted in the reasoning picker (0=Low,1=Med,2=High,3=Show,4=Hide)
    reasoning_selector_cursor: usize,
    /// Last-set reasoning effort level — cached so the picker can pre-select it
    reasoning_effort_hint: Option<String>,
    /// Personality picker overlay (activated by `/personality` with no args)
    personality_selector_active: bool,
    /// Which row is highlighted in the personality picker
    personality_selector_cursor: usize,
    /// Cached entries for the personality picker [(name, preview_text)]
    personality_selector_entries: Vec<(String, String)>,
    /// Stream picker overlay (activated by `/stream` with no args)
    stream_selector_active: bool,
    /// Which row is highlighted in the stream picker (0=ON, 1=OFF)
    stream_selector_cursor: usize,
    /// Status bar picker overlay (activated by `/statusbar` with no args)
    statusbar_selector_active: bool,
    /// Which row is highlighted in the statusbar picker (0=Visible, 1=Hidden)
    statusbar_selector_cursor: usize,
    /// Cached skill names (without leading /) for completion suggestions
    skills_completion_names: Vec<String>,
    /// Skills currently activated for injection into agent prompts.
    /// Each entry is a skill directory name under `skills_dir()`.
    /// Typing `/skill_name` toggles membership; the full SKILL.md content
    /// is prepended (hidden from display) to the next agent prompt.
    active_skills: Vec<String>,

    // ── Scroll tracking (best-practice UX) ──────────────────────────
    /// Last known terminal width (updated each render frame). Used by event
    /// handlers to build width-adaptive tool display spans outside the render loop.
    last_terminal_width: u16,
    /// Estimated total visual rows in the output area (updated each render)
    output_visual_rows: u16,
    /// Height of the last rendered output viewport (updated each render)
    output_area_height: u16,
    /// True when user is at the very bottom — new content triggers auto-scroll
    at_bottom: bool,

    // ── Dirty flag — avoid redundant redraws ─────────────────────────
    /// True whenever state changed and a redraw is needed
    needs_redraw: bool,
    /// When true, the event loop calls `terminal.clear()` on the next iteration
    /// to force a full repaint of every cell.  Set by `clear_output()` so that
    /// any out-of-band characters written to the alternate screen (e.g. from
    /// `tracing::warn!` firing on a background task) are erased.
    needs_full_terminal_clear: bool,
    /// Minimum wall-clock spacing between terminal draws. This lets the TUI
    /// adapt to slow PTYs without sacrificing API streaming or input latency.
    min_draw_interval: Duration,
    /// Active terminal UX profile derived from runtime terminal capabilities.
    terminal_ui_profile: TerminalUiProfile,
    /// Glyph set used for status and transcript accents.
    terminal_glyph_profile: TerminalGlyphProfile,
    /// Whether the current terminal session is remote (SSH / mosh).
    remote_terminal_session: bool,
    /// Whether the terminal reliably delivers dedicated PageUp / PageDown keys.
    direct_paging_keys_supported: bool,
    /// Use a shorter status bar on slow or remote terminals.
    compact_status_bar: bool,
    /// Render assistant output as markdown only on fast local terminals.
    rich_transcript: bool,
    /// Whether to render the visual scrollbar widget in the output pane.
    show_output_scrollbar: bool,
    /// Whether to show ghost text in the input widget.
    show_ghost_hint: bool,
    /// When false, periodic redraws exist only for real state changes, not
    /// cosmetic spinner / kaomoji motion. This keeps slow PTYs responsive.
    animate_status_indicators: bool,

    // ── Personality / UX animation state ─────────────────────────────
    /// Index into thinking_verbs — advances each full spinner rotation
    thinking_verb_idx: usize,
    /// Index into kaomoji_thinking — advances slower than the verb
    kaomoji_frame_idx: usize,
    /// Turn count — number of completed user→agent exchanges this session
    turn_count: usize,
    /// When the agent is waiting for a clarifying answer, this holds the
    /// oneshot sender used to relay the user's next input back to the tool.
    clarify_pending_tx: Option<tokio::sync::oneshot::Sender<String>>,
    /// When the agent is waiting for an approval choice, this holds the
    /// oneshot sender used to relay the user's `ApprovalChoice` back to the tool.
    /// The visual state is in `DisplayState::WaitingForApproval`.
    approval_pending_tx: Option<tokio::sync::oneshot::Sender<edgecrab_core::ApprovalChoice>>,
    /// When the agent is waiting for a secret value (API key, sudo password, etc.),
    /// this holds the oneshot sender used to relay the typed string back.
    /// The visual state is in `DisplayState::SecretCapture`.
    /// The buffer in `SecretCapture` is zeroed immediately after sending.
    secret_pending_tx: Option<tokio::sync::oneshot::Sender<String>>,
    /// Session-level approval cache: SHA-256 of commands approved with `Session` scope.
    /// Commands in this set skip the approval dialog for the rest of the session.
    session_approvals: std::collections::HashSet<String>,
    /// True when terminal mouse capture is enabled.
    mouse_capture_enabled: bool,
    /// Deferred terminal command to enable/disable mouse capture.
    pending_mouse_capture: Option<bool>,
    /// Timestamp of the last left-click, for double-click detection in SCROLL mode.
    last_left_click: Option<Instant>,
    /// When true, agent responses are read back via TTS after each turn.
    /// Mirrors hermes-agent's voice_mode feature (`/voice on`).
    voice_mode_enabled: bool,
    /// Accumulated text of the most recent agent response (from streaming tokens).
    /// Used by voice mode to feed TTS after each turn completes.
    last_agent_response_text: String,
    /// Hidden assistant text buffered while live token display is disabled.
    buffered_assistant_output: String,
    /// Session-level personality overlay name (e.g. "pirate", "concise").
    /// When Some, the named preset was applied this session via `/personality <name>`.
    /// Mirrors hermes-agent's `/personality` session overlay.
    session_personality: Option<String>,
    /// Session-level skin name (e.g. "dracula", "mono").
    /// Set via `/skin <name>`; used to show the active skin in `/skin` status.
    session_skin: Option<String>,
    /// Pending image paths from clipboard paste — injected into the next prompt.
    pending_images: Vec<std::path::PathBuf>,
    /// Number of tool calls currently in-flight in this turn.
    /// Incremented on ToolExec, decremented on ToolDone.
    /// Only transitions back to Thinking when this reaches zero,
    /// correctly handling parallel tool execution.
    in_flight_tool_count: u32,
    /// Cumulative streamed token count for the current turn.
    /// Persists across multiple streaming phases (separated by tool calls)
    /// so the status bar shows the running total rather than resetting.
    turn_stream_tokens: u64,
    /// In-flight tool placeholder lines keyed by stable tool-call id.
    ///
    /// WHY map instead of FIFO: root tools may execute in parallel and finish
    /// out of order. Matching by `tool_call_id` avoids upgrading the wrong line
    /// when completion events race.
    pending_tool_lines: std::collections::HashMap<String, PendingToolLine>,
    /// In-flight sub-agent placeholder lines keyed by `task_index`.
    ///
    /// Mirrors `pending_tool_lines` for the delegated-child sub-agent lifecycle:
    /// - `SubAgentStart`      → insert placeholder line + record index
    /// - `SubAgentReasoning` / `SubAgentToolExec` → update line in-place
    /// - `SubAgentFinish`     → replace with done line + remove entry
    pending_subagent_lines: std::collections::HashMap<usize, PendingSubagentLine>,
    /// Tool call ids deliberately suppressed from the transcript by display policy.
    hidden_tool_calls: HashSet<String>,
    /// Signatures already rendered in the current turn for `tool_progress: new`.
    seen_tool_signatures: HashSet<String>,
    /// Active local microphone recording session for push-to-talk voice input.
    voice_recording: Option<VoiceRecordingSession>,
    /// Configured push-to-talk key binding loaded from config.yaml.
    voice_push_to_talk_key: String,
    /// Optional recorder input-device override for cross-platform microphone capture.
    voice_input_device: Option<String>,
    /// Persisted default for hands-free continuous voice capture.
    voice_continuous_default: bool,
    /// Current continuous voice loop state for this session.
    voice_continuous_active: bool,
    /// Conservative filter for short, known-bad STT hallucinations.
    voice_hallucination_filter: bool,
    /// Consecutive empty/hallucinated voice captures in the current continuous loop.
    voice_no_speech_count: u8,
    /// True while audio is being spoken back locally.
    voice_playback_active: bool,
    /// Animation cursor for microphone / listening / speaking presence badges.
    voice_presence_frame_idx: usize,
    /// File-based hook registry. Loaded lazily from `~/.edgecrab/hooks/`
    /// before the first agent turn so startup reaches the first frame sooner.
    hook_registry: std::sync::Arc<edgecrab_gateway::hooks::HookRegistry>,
    /// Hooks are loaded lazily so the first terminal frame is not blocked by filesystem scans.
    hook_registry_loaded: bool,
    /// Consecutive automatic resume attempts triggered by local stop hooks.
    /// Prevents an unconditional `agent:stop` hook from causing an infinite loop.
    stop_hook_retry_count: u8,

    // ── Mission Steering (Phase 3) ────────────────────────────────────────
    /// Cloned sender for injecting SteeringEvent into the running agent loop.
    /// Set by `set_agent()`; cleared to None when the channel is closed.
    steer_tx: Option<edgecrab_core::SteeringSender>,
    /// Optimistic count of steers sent but not yet acknowledged by the loop.
    pending_steer_count: usize,
    /// Timestamp when the last steer was applied (for timed status bar fade).
    steer_applied_at: Option<Instant>,
    /// Whether the compact steering overlay is open.
    steering_overlay_active: bool,
    /// The kind of steer the user is composing (Hint / Redirect / Stop).
    steering_kind: edgecrab_core::SteeringKind,
    /// Single-line input textarea for the steer message.
    /// Rendered inside the compact overlay; focussed while overlay is active.
    steering_textarea: tui_textarea::TextArea<'static>,
}

#[derive(Debug, Clone)]
struct PendingToolLine {
    tool_name: String,
    args_json: String,
    line_idx: usize,
    edit_snapshot: Option<LocalEditSnapshot>,
}

/// Tracks a sub-agent placeholder output line so it can be updated in-place.
///
/// When `SubAgentStart` fires, we push a running placeholder and record its
/// index here.  `SubAgentReasoning` / `SubAgentToolExec` update it in-place.
/// `SubAgentFinish` replaces it with a structured done line.
struct PendingSubagentLine {
    /// Index into `self.output` for the placeholder line.
    line_idx: usize,
    /// Wall-clock start time — used to compute elapsed in the running line.
    started_at: Instant,
    /// Original task goal (first line of subagent prompt).
    goal: String,
    /// Total sub-agent count in this delegate batch.
    task_count: usize,
}

struct VoiceRecordingSession {
    child: std::process::Child,
    output_path: std::path::PathBuf,
    submit_to_agent: bool,
    backend: AudioRecorderBackend,
    started_at: Instant,
    profile: VoiceRecordingProfile,
    continuous_session: bool,
}

/// Events from the agent background task → TUI event loop.
enum AgentResponse {
    /// A partial streamed token — append to current streaming line.
    Token(String),
    /// A non-token runtime notice to show as a system line.
    Notice(String),
    /// Deterministic direct-tool output from the TUI (for voice commands).
    DirectToolOutput(String),
    /// A reasoning / think-mode delta or full reasoning block.
    Reasoning(String),
    /// A tool execution has started — show tool name + preview in status bar.
    ToolExec {
        tool_call_id: String,
        name: String,
        args_json: String,
    },
    /// A tool emitted an intermediate progress update.
    ToolProgress {
        tool_call_id: String,
        name: String,
        message: String,
    },
    /// A tool execution completed — push a rich formatted line to the output.
    ToolDone {
        tool_call_id: String,
        name: String,
        args_json: String,
        result_preview: Option<String>,
        duration_ms: u64,
        is_error: bool,
    },
    SubAgentStart {
        task_index: usize,
        task_count: usize,
        goal: String,
    },
    SubAgentReasoning {
        task_index: usize,
        task_count: usize,
        text: String,
    },
    SubAgentToolExec {
        task_index: usize,
        task_count: usize,
        name: String,
        args_json: String,
    },
    SubAgentFinish {
        task_index: usize,
        task_count: usize,
        status: String,
        duration_ms: u64,
        summary: String,
        api_calls: u32,
        model: Option<String>,
    },
    /// Final authoritative harness outcome for the run.
    RunFinished {
        outcome: edgecrab_types::RunOutcome,
    },
    /// Streaming complete — mark processing done.
    Done,
    /// An error occurred.
    Error(String),
    /// The agent needs an answer from the user before it can continue.
    Clarify {
        question: String,
        /// Up to 4 predefined answer choices, or None for open-ended.
        choices: Option<Vec<String>>,
        response_tx: tokio::sync::oneshot::Sender<String>,
    },
    /// The agent is requesting risk-graduated approval before executing a command.
    Approval {
        command: String,
        full_command: String,
        response_tx: tokio::sync::oneshot::Sender<edgecrab_core::ApprovalChoice>,
    },
    /// The agent is requesting a secret value (API key, env var, sudo password).
    SecretRequest {
        var_name: String,
        prompt: String,
        is_sudo: bool,
        response_tx: tokio::sync::oneshot::Sender<String>,
    },
    /// A background operation (model discovery, compress, swap) completed.
    BgOp(BackgroundOpResult),
    /// A remote skill search completed for the given request id and query.
    RemoteSkillSearchReady {
        request_id: u64,
        query: String,
        report: edgecrab_tools::tools::skills_hub::SearchReport,
    },
    /// A remote plugin search completed for the given request id and query.
    RemotePluginSearchReady {
        request_id: u64,
        query: String,
        report: edgecrab_plugins::PluginSearchReport,
    },
    /// A remote MCP search completed for the given request id and query.
    RemoteMcpSearchReady {
        request_id: u64,
        query: String,
        report: crate::mcp_catalog::McpSearchReport,
    },
    /// A remote plugin install/update action completed.
    RemotePluginActionComplete {
        message: String,
        plugin_name: String,
    },
    /// A remote plugin install/update action failed.
    RemotePluginActionFailed {
        action_label: String,
        identifier: String,
        error: String,
    },
    /// A remote skill install/update action completed.
    RemoteSkillActionComplete {
        message: String,
        skill_name: String,
    },
    /// A remote skill install/update action failed.
    RemoteSkillActionFailed {
        action_label: String,
        identifier: String,
        error: String,
    },
    /// A completed local voice transcription, optionally submitted as a prompt.
    VoiceTranscript {
        transcript: String,
        submit_to_agent: bool,
        meta: Option<VoiceTranscriptMeta>,
    },
    /// A local microphone capture failed after recording completed.
    VoiceCaptureFailed {
        error: String,
        continuous_session: bool,
    },
    /// Local voice playback finished, so continuous capture can safely resume.
    VoicePlaybackFinished,
    /// An isolated `/background` session finished successfully.
    BackgroundPromptComplete {
        task_num: u64,
        task_id: String,
        prompt_preview: String,
        response: String,
    },
    /// A background session reported progress.
    BackgroundPromptProgress {
        task_id: String,
        text: String,
    },
    /// An isolated `/background` session failed.
    BackgroundPromptFailed {
        task_num: u64,
        task_id: String,
        error: String,
    },
    SideQuestionComplete {
        question: String,
        response: String,
    },
    SideQuestionFailed {
        question: String,
        error: String,
    },
}

#[derive(Clone, Debug)]
struct BackgroundTaskStatus {
    preview: String,
    last_progress: Option<String>,
    last_seq: u64,
}

#[derive(Clone, Debug)]
struct ActiveSubagentStatus {
    task_index: usize,
    task_count: usize,
    goal: String,
    last_detail: Option<String>,
    last_seq: u64,
}

#[derive(Clone, Debug)]
struct ActiveToolStatus {
    name: String,
    args_json: String,
    last_detail: Option<String>,
    started_at: Instant,
    last_seq: u64,
}

#[derive(Clone, Debug)]
struct ActiveToolSummary {
    verb: String,
    icon: String,
    detail: String,
    elapsed_secs: u64,
}

fn background_progress_text(task_num: u64, event: &edgecrab_core::StreamEvent) -> Option<String> {
    match event {
        edgecrab_core::StreamEvent::ToolExec {
            name, args_json, ..
        } => Some(format!(
            "↳ bg#{task_num} {}",
            tool_status_preview(name, args_json)
        )),
        edgecrab_core::StreamEvent::ToolProgress { name, message, .. } => Some(format!(
            "↳ bg#{task_num} {} · {}",
            name.replace('_', " "),
            edgecrab_core::safe_truncate(message.trim(), 72)
        )),
        edgecrab_core::StreamEvent::SubAgentStart {
            task_index,
            task_count,
            goal,
        } => Some(format!(
            "↳ bg#{task_num} [{}/{}] delegate: {}",
            task_index + 1,
            task_count,
            edgecrab_core::safe_truncate(goal, 72)
        )),
        edgecrab_core::StreamEvent::SubAgentReasoning {
            task_index,
            task_count,
            text,
        } => Some(format!(
            "↳ bg#{task_num} [{}/{}] thinking: {}",
            task_index + 1,
            task_count,
            edgecrab_core::safe_truncate(text.trim(), 72)
        )),
        edgecrab_core::StreamEvent::SubAgentToolExec {
            task_index,
            task_count,
            name,
            args_json,
        } => Some(format!(
            "↳ bg#{task_num} [{}/{}] {}",
            task_index + 1,
            task_count,
            tool_status_preview(name, args_json)
        )),
        edgecrab_core::StreamEvent::SubAgentFinish {
            task_index,
            task_count,
            status,
            duration_ms,
            ..
        } => Some(format!(
            "↳ bg#{task_num} [{}/{}] {} in {:.1}s",
            task_index + 1,
            task_count,
            status,
            *duration_ms as f64 / 1000.0
        )),
        edgecrab_core::StreamEvent::RunFinished { outcome } => Some(format!(
            "↳ bg#{task_num} {}",
            edgecrab_core::safe_truncate(&format_run_outcome_notice(outcome), 96)
        )),
        _ => None,
    }
}

fn format_background_status_summary(
    active: &std::collections::HashMap<String, BackgroundTaskStatus>,
) -> Option<String> {
    let current = active.values().max_by_key(|status| status.last_seq)?;
    let detail = current
        .last_progress
        .as_deref()
        .filter(|text| !text.trim().is_empty())
        .unwrap_or(&current.preview);
    Some(edgecrab_core::safe_truncate(detail, 58).to_string())
}

fn format_subagent_status_summary(
    active: &std::collections::HashMap<usize, ActiveSubagentStatus>,
) -> Option<String> {
    let current = active.values().max_by_key(|status| status.last_seq)?;
    let detail = current
        .last_detail
        .as_deref()
        .filter(|text| !text.trim().is_empty())
        .map(|text| edgecrab_core::safe_truncate(text, 52).to_string())
        .unwrap_or_else(|| edgecrab_core::safe_truncate(&current.goal, 52).to_string());
    Some(format!(
        "[{}/{}] {}",
        current.task_index + 1,
        current.task_count,
        detail
    ))
}

fn latest_active_tool_entry(
    active: &std::collections::HashMap<String, ActiveToolStatus>,
) -> Option<(&String, &ActiveToolStatus)> {
    active.iter().max_by_key(|(_, status)| status.last_seq)
}

fn summarize_active_tools(
    active: &std::collections::HashMap<String, ActiveToolStatus>,
) -> Option<ActiveToolSummary> {
    let (_, current) = latest_active_tool_entry(active)?;
    let mut detail = current
        .last_detail
        .as_deref()
        .filter(|text| !text.trim().is_empty())
        .map(|text| edgecrab_core::safe_truncate(text.trim(), 52).to_string())
        .unwrap_or_else(|| {
            edgecrab_core::safe_truncate(
                &tool_status_preview(&current.name, &current.args_json),
                52,
            )
            .to_string()
        });
    let count = active.len();
    if count > 1 {
        detail.push_str(&format!("  +{} more", count - 1));
    }
    let elapsed_secs = active
        .values()
        .map(|status| status.started_at.elapsed().as_secs())
        .max()
        .unwrap_or(0);
    Some(ActiveToolSummary {
        verb: if count > 1 {
            "running".into()
        } else {
            tool_action_verb(&current.name).into()
        },
        icon: tool_icon(&current.name).into(),
        detail,
        elapsed_secs,
    })
}

// ── Picker overlay shared helpers ────────────────────────────────────────────

/// Calculate a centered popup rectangle clamped to the available terminal area.
fn popup_rect(area: Rect, w: u16, h: u16) -> Rect {
    let pw = w.min(area.width);
    let ph = h.min(area.height);
    Rect {
        x: area.x + area.width.saturating_sub(pw) / 2,
        y: area.y + area.height.saturating_sub(ph) / 2,
        width: pw,
        height: ph,
    }
}

/// Standard 3-section vertical layout for picker overlays:
/// `[0]` header (3 rows), `[1]` body (min), `[2]` help bar (1 row).
fn picker_three_layout(popup: Rect) -> std::rc::Rc<[Rect]> {
    Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(1),
            Constraint::Length(1),
        ])
        .split(popup)
}

/// Split a body area into `[0]` list pane and `[1]` detail pane.
fn picker_two_cols(body: Rect, list_pct: u16) -> std::rc::Rc<[Rect]> {
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(list_pct),
            Constraint::Percentage(100u16.saturating_sub(list_pct)),
        ])
        .split(body)
}

/// Standard help-bar `Line` for picker overlays: `↑↓ browse  Tab next  Enter apply  Esc cancel`.
fn picker_help_line(accent: Color) -> Line<'static> {
    Line::from(vec![
        Span::styled(" ↑↓ ", Style::default().fg(accent)),
        Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
        Span::styled("Tab ", Style::default().fg(accent)),
        Span::styled("next  ", Style::default().fg(Color::DarkGray)),
        Span::styled("Enter ", Style::default().fg(accent)),
        Span::styled("apply  ", Style::default().fg(Color::DarkGray)),
        Span::styled("Esc ", Style::default().fg(accent)),
        Span::styled("cancel", Style::default().fg(Color::DarkGray)),
    ])
}

// ─────────────────────────────────────────────────────────────────────────────

impl App {
    fn clear_active_request_state(&mut self) {
        self.is_processing = false;
        self.active_request_abort = None;
        self.streaming_line = None;
        self.reasoning_line = None;
        self.buffered_assistant_output.clear();
        self.in_flight_tool_count = 0;
        self.active_subagents.clear();
        self.turn_stream_tokens = 0;
        self.pending_tool_lines.clear();
        self.pending_subagent_lines.clear();
        self.hidden_tool_calls.clear();
        self.active_tools.clear();
        self.seen_tool_signatures.clear();
        self.display_state = DisplayState::Idle;
    }

    fn cancel_active_request(&mut self) -> bool {
        let had_active_request = self.is_processing;
        if let Some(handle) = self.active_request_abort.take() {
            handle.abort();
        }
        if let Some(ref agent) = self.agent {
            agent.interrupt();
        }
        if had_active_request {
            self.clear_active_request_state();
            self.needs_redraw = true;
        }
        had_active_request
    }

    // ── Mission Steering helpers ───────────────────────────────────────────

    /// Open the compact steering overlay, resetting its input and kind.
    fn open_steering_overlay(&mut self) {
        // Clear any previous text in the steering textarea.
        let lines: Vec<String> = self
            .steering_textarea
            .lines()
            .iter()
            .map(|l| l.to_string())
            .collect();
        for _ in 0..lines.len() {
            self.steering_textarea
                .move_cursor(tui_textarea::CursorMove::End);
            self.steering_textarea.delete_line_by_head();
        }
        self.steering_overlay_active = true;
        self.needs_redraw = true;
    }

    /// Handle a key event while the steering overlay is open.
    fn handle_steering_overlay_key(&mut self, key: event::KeyEvent) {
        match (key.modifiers, key.code) {
            // Esc or Ctrl+S (toggle) — close without sending
            (_, KeyCode::Esc) | (KeyModifiers::CONTROL, KeyCode::Char('s')) => {
                self.steering_overlay_active = false;
                self.needs_redraw = true;
            }
            // Tab — cycle SteeringKind: Hint → Redirect → Stop → Hint
            (_, KeyCode::Tab) => {
                use edgecrab_core::SteeringKind;
                self.steering_kind = match self.steering_kind {
                    SteeringKind::Hint => SteeringKind::Redirect,
                    SteeringKind::Redirect => SteeringKind::Stop,
                    SteeringKind::Stop => SteeringKind::Hint,
                };
                self.needs_redraw = true;
            }
            // Enter — send the steer and close
            (_, KeyCode::Enter) => {
                self.send_steer_from_overlay();
            }
            // All other keys — route to the steering textarea
            _ => {
                self.steering_textarea.input(key);
                self.needs_redraw = true;
            }
        }
    }

    /// Send the steer composed in the overlay, then close it.
    fn send_steer_from_overlay(&mut self) {
        let text: String = self.steering_textarea.lines().join("\n");
        let text = text.trim().to_string();
        self.steering_overlay_active = false;
        self.needs_redraw = true;

        if text.is_empty() {
            return;
        }

        let kind = self.steering_kind.clone();

        // EC-04: agent is idle → treat as new user message promoted with tag.
        if !self.is_processing {
            let promoted = format!(
                "[⛵ STEER/{kind}] {text}",
                kind = match &kind {
                    edgecrab_core::SteeringKind::Hint => "HINT",
                    edgecrab_core::SteeringKind::Redirect => "REDIRECT",
                    edgecrab_core::SteeringKind::Stop => "STOP",
                },
            );
            self.push_output(
                "⛵ Steering → sent as new message (agent idle)".to_string(),
                OutputRole::System,
            );
            self.process_input(&promoted);
            return;
        }

        // Agent is running — send via steering channel.
        let send_result = self.steer_tx.as_ref().map(|tx| {
            tx.send(edgecrab_core::SteeringEvent::new(
                kind.clone(),
                text.clone(),
            ))
        });

        match send_result {
            Some(Ok(())) => {
                self.pending_steer_count += 1;
                // Show a brief acknowledgement in the output.
                self.push_output(
                    format!(
                        "⛵ Steer queued ({}/{})",
                        match &kind {
                            edgecrab_core::SteeringKind::Hint => "HINT",
                            edgecrab_core::SteeringKind::Redirect => "REDIRECT",
                            edgecrab_core::SteeringKind::Stop => "STOP",
                        },
                        edgecrab_core::safe_truncate(&text, 60),
                    ),
                    OutputRole::System,
                );
                self.needs_redraw = true;
            }
            Some(Err(_)) => {
                // Channel closed — clear steer_tx and notify.
                self.steer_tx = None;
                self.push_output(
                    "⚠ Steer channel closed (new session?). Steering as new message.".to_string(),
                    OutputRole::Error,
                );
                // Fallback: send as new queued message.
                let promoted = format!("[⛵ STEER] {text}");
                self.process_input(&promoted);
            }
            None => {
                // No steer_tx — no agent set yet
                self.push_output(
                    "⚠ No active agent for steering. Send as new message instead.".to_string(),
                    OutputRole::Error,
                );
            }
        }
    }

    fn flush_buffered_assistant_output(&mut self) {
        if self.buffered_assistant_output.is_empty() {
            return;
        }

        let text = std::mem::take(&mut self.buffered_assistant_output);
        if let Some(idx) = self.streaming_line {
            if idx < self.output.len() {
                self.output[idx].text.push_str(&text);
                self.output[idx].invalidate_render_cache();
            } else {
                self.output.push(OutputLine {
                    text,
                    role: OutputRole::Assistant,
                    prebuilt_spans: None,
                    rendered: None,
                    plain_rendered: None,
                });
                self.streaming_line = Some(self.output.len() - 1);
            }
        } else {
            self.output.push(OutputLine {
                text,
                role: OutputRole::Assistant,
                prebuilt_spans: None,
                rendered: None,
                plain_rendered: None,
            });
            self.streaming_line = Some(self.output.len() - 1);
        }

        if self.at_bottom {
            self.scroll_offset = 0;
        }
        self.needs_redraw = true;
    }

    pub fn new() -> Self {
        let (response_tx, response_rx) = mpsc::unbounded_channel();
        let (cron_tx, cron_rx) = mpsc::unbounded_channel();
        let theme = Theme::load();
        let commands = CommandRegistry::new();
        let display_preferences = load_display_preferences();

        // Collect all command names + aliases for tab completion
        let all_command_names = {
            let mut names: Vec<String> = commands
                .all_names()
                .into_iter()
                .map(|n| format!("/{n}"))
                .collect();
            names.sort();
            names.dedup();
            names
        };

        // Build name → description lookup (aliases share parent description)
        let command_descriptions = commands.all_descriptions();

        // Configure tui-textarea
        let mut textarea = TextArea::default();
        textarea.set_max_histories(512);
        textarea.set_tab_length(4);
        textarea.set_wrap_mode(WrapMode::WordOrGlyph);
        textarea.set_cursor_line_style(Style::default());
        textarea.set_block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(theme.input_border)
                .title(format!(" {} ", theme.prompt_symbol.trim())),
        );
        textarea.set_style(theme.input_text);
        textarea.set_placeholder_text("Type a message or /help for commands...");
        textarea.set_placeholder_style(
            // WCAG AA: Rgb(135,135,148) L=0.22 CR=5.0:1 vs black; ITALIC has no luminance effect.
            Style::default()
                .fg(Color::Rgb(135, 135, 148))
                .add_modifier(Modifier::ITALIC),
        );

        let runtime_config = edgecrab_core::AppConfig::load().unwrap_or_default();
        let terminal_caps = terminal_runtime_capabilities();

        let mut app = Self {
            textarea,
            editor_mode: InputEditorMode::Inline,
            vim_pending: None,
            keyboard_enhancement_enabled: false,
            output: Vec::new(),
            scroll_offset: 0,
            theme,
            commands,
            should_exit: false,
            model_name: "none".into(),
            context_window: None,
            total_tokens: 0,
            session_cost: 0.0,
            agent: None,
            rt_handle: tokio::runtime::Handle::current(),
            response_rx,
            response_tx,
            background_task_seq: 0,
            progress_seq: 0,
            background_tasks_active: std::collections::HashMap::new(),
            active_tools: std::collections::HashMap::new(),
            active_subagents: std::collections::HashMap::new(),
            cron_rx,
            cron_tx,
            is_processing: false,
            active_request_abort: None,
            streaming_line: None,
            reasoning_line: None,
            show_reasoning: display_preferences.show_reasoning,
            streaming_enabled: display_preferences.streaming_enabled,
            live_token_display_enabled: display_preferences.streaming_enabled
                && terminal_caps.live_token_display_enabled,
            tool_progress_mode: display_preferences.tool_progress_mode,
            show_status_bar: display_preferences.show_status_bar,
            yolo_enabled: false,
            prompt_queue: Vec::new(),
            display_state: DisplayState::Idle,
            completion: CompletionState {
                candidates: Vec::new(),
                selected: 0,
                active: false,
                arg_start: 0,
            },
            input_history: Vec::new(),
            history_loaded: false,
            history_pos: 0,
            history_stash: String::new(),
            last_response_time: None,
            last_ttfb_secs: None,
            last_run_outcome: None,
            all_command_names,
            command_descriptions,
            model_selector: FuzzySelector::new(),
            model_selector_seeded: false,
            model_selector_target: ModelSelectorTarget::Primary,
            model_selector_refresh_in_flight: false,
            vision_model_selector: FuzzySelector::new(),
            image_model_selector: FuzzySelector::new(),
            moa_reference_selector: FuzzySelector::new(),
            moa_reference_selected: BTreeSet::new(),
            moa_reference_selector_mode: MoaReferenceSelectorMode::EditRoster,
            mcp_selector: FuzzySelector::new(),
            remote_mcp_browser: RemoteMcpBrowser::new(),
            profile_selector: FuzzySelector::new(),
            profile_detail_mode: ProfileDetailMode::Summary,
            skill_selector: FuzzySelector::new(),
            tool_manager: FuzzySelector::new(),
            tool_manager_scope: ToolManagerScope::All,
            tool_manager_status_note: None,
            plugin_toggle: FuzzySelector::new(),
            plugin_toggle_scope: PluginScope::Global,
            plugin_toggle_status_note: None,
            remote_plugin_browser: RemotePluginBrowser::new(),
            remote_skill_browser: RemoteSkillBrowser::new(),
            session_browser: FuzzySelector::new(),
            session_browser_status_note: None,
            session_browser_pane: DetailPaneState::default(),
            log_browser: FuzzySelector::new(),
            log_browser_status_note: None,
            log_browser_pane: DetailPaneState::default(),
            detail_fullscreen: None,
            simple_detail_state: SimpleDetailState::default(),
            document_overlay: None,
            session_inspector: SessionInspector::new(),
            log_inspector: LogInspector::new(),
            log_follow: LogFollowState::new(),
            config_selector: FuzzySelector::new(),
            gateway_browser: FuzzySelector::new(),
            gateway_browser_pane: DetailPaneState::default(),
            diagnose_panel: DiagnosePanelState::default(),
            skin_browser: FuzzySelector::new(),
            verbose_selector_active: false,
            verbose_selector_cursor: 3, // default to Verbose (the new default mode)
            reasoning_selector_active: false,
            reasoning_selector_cursor: 1, // default to Medium
            reasoning_effort_hint: None,
            personality_selector_active: false,
            personality_selector_cursor: 0,
            personality_selector_entries: Vec::new(),
            stream_selector_active: false,
            stream_selector_cursor: 0, // default to ON
            statusbar_selector_active: false,
            statusbar_selector_cursor: 0, // default to Visible
            skills_completion_names: Vec::new(),
            active_skills: Vec::new(),
            last_terminal_width: 80,
            output_visual_rows: 0,
            output_area_height: 24,
            at_bottom: true,
            needs_redraw: true,
            needs_full_terminal_clear: false,
            min_draw_interval: terminal_caps.min_draw_interval,
            terminal_ui_profile: terminal_caps.ui_profile,
            terminal_glyph_profile: terminal_caps.glyph_profile,
            remote_terminal_session: terminal_caps.remote_session,
            direct_paging_keys_supported: terminal_caps.direct_paging_keys_supported,
            compact_status_bar: terminal_caps.compact_status_bar,
            rich_transcript: terminal_caps.rich_transcript,
            show_output_scrollbar: terminal_caps.show_output_scrollbar,
            show_ghost_hint: terminal_caps.show_ghost_hint,
            animate_status_indicators: terminal_caps.animate_status_indicators,
            thinking_verb_idx: 0,
            kaomoji_frame_idx: 0,
            turn_count: 0,
            clarify_pending_tx: None,
            approval_pending_tx: None,
            secret_pending_tx: None,
            session_approvals: std::collections::HashSet::new(),
            mouse_capture_enabled: terminal_caps.mouse_capture_enabled,
            pending_mouse_capture: None,
            last_left_click: None,
            voice_mode_enabled: load_voice_mode_enabled() && voice_readback_ready().is_ok(),
            last_agent_response_text: String::new(),
            buffered_assistant_output: String::new(),
            session_personality: None,
            session_skin: None,
            pending_images: Vec::new(),
            in_flight_tool_count: 0,
            turn_stream_tokens: 0,
            pending_tool_lines: std::collections::HashMap::new(),
            pending_subagent_lines: std::collections::HashMap::new(),
            hidden_tool_calls: HashSet::new(),
            seen_tool_signatures: HashSet::new(),
            voice_recording: None,
            voice_push_to_talk_key: runtime_config.voice.push_to_talk_key,
            voice_input_device: runtime_config.voice.input_device,
            voice_continuous_default: runtime_config.voice.continuous,
            voice_continuous_active: false,
            voice_hallucination_filter: runtime_config.voice.hallucination_filter,
            voice_no_speech_count: 0,
            voice_playback_active: false,
            voice_presence_frame_idx: 0,
            hook_registry: std::sync::Arc::new(edgecrab_gateway::hooks::HookRegistry::new()),
            hook_registry_loaded: false,
            stop_hook_retry_count: 0,
            // ── Mission Steering ────────────────────────────────────────────
            steer_tx: None,
            pending_steer_count: 0,
            steer_applied_at: None,
            steering_overlay_active: false,
            steering_kind: edgecrab_core::SteeringKind::Hint,
            steering_textarea: {
                let mut ta = tui_textarea::TextArea::default();
                ta.set_cursor_line_style(ratatui::style::Style::default());
                ta.set_block(
                    ratatui::widgets::Block::default().borders(ratatui::widgets::Borders::NONE),
                );
                ta.set_placeholder_text("Type your steering hint...");
                ta.set_placeholder_style(
                    ratatui::style::Style::default()
                        // WCAG AA: Rgb(135,145,165) L=0.27 CR=6.4:1 vs black.
                        .fg(ratatui::style::Color::Rgb(135, 145, 165))
                        .add_modifier(ratatui::style::Modifier::ITALIC),
                );
                ta
            },
        };

        app.apply_textarea_editor_style();

        app
    }

    /// Clone the cron notification sender for use by the background cron ticker.
    ///
    /// The background ticker sends formatted completion messages through this
    /// channel. The TUI drains it in `check_responses()` on every event-loop tick.
    pub fn cron_sender(&self) -> mpsc::UnboundedSender<String> {
        self.cron_tx.clone()
    }

    /// Set the agent for LLM dispatch.
    pub fn set_agent(&mut self, agent: Arc<Agent>) {
        self.active_skills = agent.preloaded_skills_snapshot();
        self.refresh_skills_list();
        // Capture the steering sender so Ctrl+S can inject guidance into the loop.
        self.steer_tx = Some(agent.steer_sender());
        // Reset steering UI state for the new session.
        self.pending_steer_count = 0;
        self.steer_applied_at = None;
        self.steering_overlay_active = false;
        self.agent = Some(agent);
    }

    pub fn set_yolo_enabled(&mut self, enabled: bool) {
        self.yolo_enabled = enabled;
    }

    fn ensure_history_loaded(&mut self) {
        if self.history_loaded {
            return;
        }
        self.load_history_file();
        self.history_loaded = true;
        self.history_pos = self.input_history.len();
    }

    fn ensure_model_selector_seeded(&mut self) {
        if self.model_selector_seeded {
            return;
        }
        self.model_selector.set_items(build_model_selector_entries(
            &ModelCatalog::grouped_catalog(),
            None,
        ));
        self.model_selector_seeded = true;
    }

    fn ensure_hook_registry_loaded(&mut self) {
        if self.hook_registry_loaded {
            return;
        }
        self.reload_hook_registry();
    }

    fn reload_hook_registry(&mut self) {
        let mut registry = edgecrab_gateway::hooks::HookRegistry::new();
        registry.discover_and_load();
        self.hook_registry = std::sync::Arc::new(registry);
        self.hook_registry_loaded = true;
    }

    fn maybe_apply_stop_hooks(
        &mut self,
        mut outcome: edgecrab_types::RunOutcome,
    ) -> edgecrab_types::RunOutcome {
        use edgecrab_gateway::hooks::HookResult;
        use edgecrab_types::{CompletionDecision, ExitReason};

        self.ensure_hook_registry_loaded();

        let state_event = stop_hook_event_name(&outcome).to_string();
        let registry = self.hook_registry.clone();
        let run_finished_ctx = build_run_outcome_hook_context("agent:run_finished", &outcome);
        let done_ctx = build_run_outcome_hook_context("agent:done", &outcome);
        let specific_ctx = build_run_outcome_hook_context(&state_event, &outcome);
        let stop_ctx = build_run_outcome_hook_context("agent:stop", &outcome);

        let hook_result = self.rt_handle.block_on(async move {
            registry.emit("agent:run_finished", &run_finished_ctx).await;
            registry.emit("agent:done", &done_ctx).await;
            match registry.emit_cancellable(&state_event, &specific_ctx).await {
                cancel @ HookResult::Cancel { .. } => cancel,
                HookResult::Continue => registry.emit_cancellable("agent:stop", &stop_ctx).await,
            }
        });

        match hook_result {
            HookResult::Continue => {
                self.stop_hook_retry_count = 0;
                outcome
            }
            HookResult::Cancel { reason } => {
                let reason = reason.trim();
                let reason_text = if reason.is_empty() {
                    "a local stop hook requested another pass before completion can be accepted"
                } else {
                    reason
                };

                self.push_output(
                    format!("🪝 Stop hook review\n{}", reason_text),
                    OutputRole::System,
                );

                let can_resume = matches!(
                    outcome.state,
                    CompletionDecision::Completed
                        | CompletionDecision::Incomplete
                        | CompletionDecision::NeedsVerification
                );

                if can_resume && self.stop_hook_retry_count < 3 {
                    self.stop_hook_retry_count = self.stop_hook_retry_count.saturating_add(1);
                    self.prompt_queue
                        .insert(0, build_stop_hook_followup_prompt(reason_text));
                    outcome.state = CompletionDecision::Incomplete;
                    outcome.exit_reason = ExitReason::PendingTasks;
                    outcome.user_summary = format!(
                        "A local stop hook requested another pass before completion: {}",
                        reason_text
                    );
                } else {
                    outcome.state = CompletionDecision::Blocked;
                    outcome.exit_reason = ExitReason::PendingTasks;
                    outcome.user_summary =
                        format!("Completion paused by a local stop hook: {}", reason_text);
                }

                outcome
            }
        }
    }

    fn handle_show_hooks(&mut self, args: String) {
        let normalized = args.trim().to_ascii_lowercase();
        if matches!(normalized.as_str(), "reload" | "refresh") {
            self.reload_hook_registry();
            self.push_output(
                format!("Reloaded local hooks from {}.", hook_home_display_path()),
                OutputRole::System,
            );
        } else {
            self.ensure_hook_registry_loaded();
        }

        let hooks = self.hook_registry.loaded_hooks();
        match normalized.as_str() {
            "status" => {
                self.push_output(format_hook_status_line(hooks), OutputRole::System);
            }
            "help" => {
                self.open_document_overlay(
                    "Hooks",
                    "User-configurable lifecycle automation",
                    format!(
                        "{}\n\n{}",
                        format_hook_help_body(),
                        format_loaded_hooks_report(hooks)
                    ),
                    "🪝",
                    Color::Rgb(128, 188, 255),
                );
            }
            "" | "list" | "reload" | "refresh" => {
                let body = format!(
                    "{}\n\n{}",
                    format_hook_help_body(),
                    format_loaded_hooks_report(hooks)
                );
                self.open_document_overlay(
                    "Hooks",
                    "Local lifecycle hook registry",
                    body,
                    "🪝",
                    Color::Rgb(128, 188, 255),
                );
            }
            other => {
                self.push_output(
                    format!("Unknown /hooks option: {other}. Try /hooks help."),
                    OutputRole::System,
                );
            }
        }
    }

    /// Get a reference to the agent, or push an error and return None.
    fn require_agent(&mut self) -> Option<Arc<Agent>> {
        match self.agent.clone() {
            Some(a) => Some(a),
            None => {
                self.push_output("No agent configured.", OutputRole::Error);
                None
            }
        }
    }

    /// Blocking snapshot from agent session state.
    fn agent_snapshot(&self, agent: &Agent) -> edgecrab_core::SessionSnapshot {
        self.rt_handle
            .block_on(async { agent.session_snapshot().await })
    }

    /// Set model name for status bar display.
    pub fn set_model(&mut self, model: &str) {
        self.model_name = model.to_string();
        self.update_context_window();
    }

    /// Refresh `self.context_window` from the model catalog for the current model.
    ///
    /// Model names are in the format `provider/model` (e.g. `anthropic/claude-opus-4`).
    /// When the model is not found in the catalog, context_window is set to None.
    fn update_context_window(&mut self) {
        self.context_window = self
            .model_name
            .split_once('/')
            .and_then(|(provider, model)| ModelCatalog::context_window(provider, model));
    }

    /// Replace the visible transcript with a persisted message history.
    pub fn load_messages(&mut self, messages: Vec<edgecrab_types::Message>) {
        self.clear_output();
        for message in messages {
            match message.role {
                edgecrab_types::Role::System => {
                    self.push_output(message.text_content(), OutputRole::System);
                }
                edgecrab_types::Role::User => {
                    self.push_output(format!("> {}", message.text_content()), OutputRole::User);
                }
                edgecrab_types::Role::Assistant => {
                    if self.show_reasoning
                        && let Some(reasoning) = message.reasoning.clone()
                        && !reasoning.trim().is_empty()
                    {
                        self.push_output(format!("Thinking\n{reasoning}"), OutputRole::Reasoning);
                    }
                    self.push_output(message.text_content(), OutputRole::Assistant);
                }
                edgecrab_types::Role::Tool => {
                    self.push_output(message.text_content(), OutputRole::Tool);
                }
            }
        }
    }

    /// Add a line to the output area.
    pub fn push_output(&mut self, text: impl Into<String>, role: OutputRole) {
        self.output.push(OutputLine {
            text: text.into(),
            role,
            prebuilt_spans: None,
            rendered: None,
            plain_rendered: None,
        });
        // Only auto-scroll to bottom if the user was already at bottom.
        // Preserves scroll position when user has scrolled up to read history.
        if self.at_bottom {
            self.scroll_offset = 0;
        }
        self.needs_redraw = true;
    }

    /// Push a pre-built span line (for tool-done lines with emoji).
    /// Ratatui renders each Span with correct unicode column width,
    /// so emoji/wide characters align perfectly without format-string padding tricks.
    pub fn push_output_spans(&mut self, spans: Vec<Span<'static>>, role: OutputRole) {
        self.output.push(OutputLine {
            text: String::new(),
            role,
            prebuilt_spans: Some(spans),
            rendered: None,
            plain_rendered: None,
        });
        if self.at_bottom {
            self.scroll_offset = 0;
        }
        self.needs_redraw = true;
    }

    /// Clear the output area.
    pub fn clear_output(&mut self) {
        self.output.clear();
        self.scroll_offset = 0;
        self.at_bottom = true;
        self.output_visual_rows = 0;
        // Reset streaming cursors so any in-flight agent events are handled
        // correctly: stale indices into the now-empty output vec would cause
        // tokens to be silently dropped or appended at wrong positions.
        self.streaming_line = None;
        self.reasoning_line = None;
        self.buffered_assistant_output.clear();
        self.pending_tool_lines.clear();
        self.hidden_tool_calls.clear();
        self.active_tools.clear();
        self.seen_tool_signatures.clear();
        // Request a full terminal repaint.  ratatui's diff-based renderer
        // normally skips unchanged cells; if any out-of-band bytes reached the
        // alternate screen (e.g. tracing::warn! from a background task) those
        // cells are only erased by a full clear → next draw writes every cell.
        self.needs_full_terminal_clear = true;
        self.needs_redraw = true;
    }

    fn start_fresh_session(&mut self, status_message: Option<&str>, show_banner: bool) {
        if let Some(ref agent) = self.agent {
            let agent = Arc::clone(agent);
            self.rt_handle.block_on(async move {
                agent.new_session().await;
            });
        }
        self.yolo_enabled = false;
        // ── Reset UI-layer counters that the status bar reads ──────────────────
        // WHY: agent.new_session() clears the agent-side SessionState, but the
        // App struct caches derived display values (total_tokens, session_cost,
        // turn_count, etc.) that are only updated when a response arrives.
        // Without resetting them here the status bar continues to show the
        // previous session's token count / cost until the first new API call
        // completes — giving the misleading impression that the new session
        // already has a large context.
        self.total_tokens = 0;
        self.session_cost = 0.0;
        self.turn_count = 0;
        self.last_response_time = None;
        self.last_agent_response_text.clear();
        self.in_flight_tool_count = 0;
        self.active_subagents.clear();
        self.last_run_outcome = None;
        self.display_state = DisplayState::Idle;
        self.clear_output();
        if show_banner {
            let model = self.model_name.clone();
            self.push_colorful_banner(&model);
        }
        if let Some(message) = status_message {
            self.push_output(message, OutputRole::System);
        }
    }

    /// Push a borderless gradient welcome banner into the output area.
    ///
    /// # Design rationale — no box drawing
    ///
    /// Box-drawing characters (╔═╗╚╝║) are visually heavy and interact poorly
    /// with emoji content that follows — they set a rigid "formal" tone that
    /// clashes with the rest of the TUI.  Instead we use a lightweight wordmark
    /// layout:
    ///
    /// ```text
    ///   🦀  EdgeCrab           ·  AI-native terminal agent
    ///   ────────────────────────────────────────────────────
    ///      Model: claude-opus-4
    ///      Type a message or /help for commands…
    /// ```
    ///
    /// # Emoji-safe layout rules
    ///
    /// Wide chars (emoji, katakana, CJK) are **2 display columns** wide.  All
    /// column arithmetic uses `.width()` (UnicodeWidthStr) and
    /// `unicode_pad_right()`, never `.len()` or `format!("{:<n}")`.  Emoji are
    /// isolated in their own `Span` so ratatui measures each segment
    /// independently.
    pub fn push_colorful_banner(&mut self, model: &str) {
        let agent_name = self.theme.agent_name.clone();
        let welcome_msg = self.theme.welcome_msg.clone();

        // ── Palette ────────────────────────────────────────────────────────────
        let crab_style = Style::default()
            .fg(Color::Rgb(255, 160, 40))
            .add_modifier(Modifier::BOLD); // vivid copper
        let name_style = Style::default()
            .fg(Color::Rgb(255, 215, 0))
            .add_modifier(Modifier::BOLD); // gold
        let dot_style = Style::default().fg(Color::Rgb(148, 148, 165)); // WCAG AA: L=0.29 CR=6.3:1
        let tagline_style = Style::default().fg(Color::Rgb(184, 134, 11)); // dark gold — base CR=6.5:1; DIM removed for AA compliance
        let rule_style = Style::default().fg(P::SEP_LINE).add_modifier(Modifier::DIM); // purely decorative rule — DIM OK (SC 1.4.3 exempt)
        // WCAG AA: SECONDARY_WARM Rgb(165,165,178) CR=9.2:1; DIM removed (DIM breaks AA).
        let label_style = Style::default().fg(P::SECONDARY_WARM);
        let value_style = Style::default().fg(Color::Rgb(255, 191, 0)); // amber
        // WCAG AA: SECONDARY_WARM for welcome hint; DIM removed for AA compliance.
        let hint_style = Style::default().fg(P::SECONDARY_WARM);

        // ── Row 0: blank breathing room ────────────────────────────────────────
        self.push_output_spans(vec![Span::raw("")], OutputRole::System);

        // ── Row 1: wordmark  🦀  Name  ·  tagline ─────────────────────────────
        //
        // Layout:  "  🦀 " (5) + name_padded (18) + " · " (3) + tagline
        // All widths measured with .width(); emoji in own Span.
        let name_cell = unicode_pad_right(&agent_name, 18);
        let tagline = "AI-native terminal agent";
        self.push_output_spans(
            vec![
                Span::styled("  ", Style::default()),
                // 🦀 = 2 display cols; isolated so ratatui measures it cleanly.
                Span::styled("🦀 ", crab_style),
                Span::styled(name_cell, name_style),
                Span::styled(" · ", dot_style),
                Span::styled(tagline.to_string(), tagline_style),
            ],
            OutputRole::System,
        );

        // ── Row 2: thin rule (no box chars — just a repeated ─) ────────────────
        //
        // 52 cols: matches the visual span of the wordmark line above.
        // "─" is U+2500 (box-drawing); it degrades gracefully on narrow fonts.
        let rule = "─".repeat(52);
        self.push_output_spans(
            vec![Span::styled(format!("  {rule}"), rule_style)],
            OutputRole::System,
        );

        // ── Row 3: model ────────────────────────────────────────────────────────
        let model_display = unicode_trunc(model, 55);
        self.push_output_spans(
            vec![
                Span::styled("     ", Style::default()),
                Span::styled("Version  ", label_style),
                Span::styled(format!("v{}", crate::banner::VERSION), value_style),
                Span::styled("   Model  ", label_style),
                Span::styled(model_display, value_style),
            ],
            OutputRole::System,
        );

        // ── Row 4: tools & skills counts ────────────────────────────────────────
        // Use block_in_place (safe inside spawn_blocking) instead of block_on
        // which panics when called from within a tokio runtime thread.
        let tool_count = if let Some(agent) = self.agent.clone() {
            tokio::task::block_in_place(|| {
                self.rt_handle
                    .block_on(async move { agent.tool_names().await })
            })
            .len()
        } else {
            0
        };
        let skill_count = self.skills_completion_names.len();
        let count_style = Style::default()
            .fg(Color::Rgb(160, 200, 160))
            .add_modifier(Modifier::DIM);
        self.push_output_spans(
            vec![
                Span::styled("     ", Style::default()),
                Span::styled(
                    format!("tools: {tool_count}   skills: {skill_count}"),
                    count_style,
                ),
            ],
            OutputRole::System,
        );

        // ── Row 5: welcome hint ─────────────────────────────────────────────────
        self.push_output_spans(
            vec![
                Span::styled("     ", Style::default()),
                Span::styled(welcome_msg, hint_style),
            ],
            OutputRole::System,
        );

        // ── Row 6: blank breathing room ────────────────────────────────────────
        self.push_output_spans(vec![Span::raw("")], OutputRole::System);

        // ── Async update check ──────────────────────────────────────────────────
        let runtime_config = self.load_runtime_config();
        let update_state = crate::update::cached_startup_state(&runtime_config);
        if let Some(notice) = update_state.cached_notice {
            self.push_output(notice, OutputRole::System);
        }
        if update_state.should_refresh {
            let tx = self.response_tx.clone();
            let config = runtime_config.clone();
            let previous_latest_version = update_state.known_latest_version;
            self.rt_handle.spawn(async move {
                if let Ok(outcome) = crate::update::refresh_update_status(
                    &config,
                    previous_latest_version.as_deref(),
                )
                .await
                    && let Some(notice) = outcome.startup_notice
                {
                    let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::SystemMsg(notice)));
                }
            });
        }
    }

    /// Push the goodbye message from the current skin into the output.
    #[allow(dead_code)]
    pub fn push_goodbye(&mut self) {
        let msg = self.theme.goodbye_msg.clone();
        self.push_output_spans(
            vec![Span::styled(
                format!("  {msg}"),
                Style::default()
                    .fg(Color::Rgb(184, 134, 11))
                    .add_modifier(Modifier::DIM),
            )],
            OutputRole::System,
        );
    }

    /// Returns true if the app should exit.
    pub fn should_exit(&self) -> bool {
        self.should_exit
    }

    /// Get current text from textarea as a single string.
    fn textarea_text(&self) -> String {
        self.textarea.lines().join("\n")
    }

    fn apply_textarea_editor_style(&mut self) {
        match self.editor_mode {
            InputEditorMode::Inline => {
                self.textarea.set_cursor_line_style(Style::default());
                self.textarea.set_cursor_style(
                    Style::default()
                        .fg(Color::Rgb(255, 248, 220))
                        .add_modifier(Modifier::REVERSED),
                );
                self.textarea.remove_line_number();
            }
            InputEditorMode::ComposeInsert => {
                self.textarea
                    .set_cursor_line_style(Style::default().bg(Color::Rgb(34, 38, 48)));
                self.textarea.set_cursor_style(
                    Style::default()
                        .fg(Color::Rgb(100, 230, 160))
                        .add_modifier(Modifier::REVERSED | Modifier::BOLD),
                );
                self.textarea
                    .set_line_number_style(Style::default().fg(Color::Rgb(85, 95, 110)));
            }
            InputEditorMode::ComposeNormal => {
                self.textarea
                    .set_cursor_line_style(Style::default().bg(Color::Rgb(45, 34, 18)));
                self.textarea.set_cursor_style(
                    Style::default()
                        .fg(Color::Rgb(255, 191, 0))
                        .add_modifier(Modifier::REVERSED | Modifier::BOLD),
                );
                self.textarea
                    .set_line_number_style(Style::default().fg(Color::Rgb(120, 105, 70)));
            }
        }
    }

    fn fresh_textarea(&self) -> TextArea<'static> {
        let mut fresh = TextArea::default();
        fresh.set_max_histories(512);
        fresh.set_tab_length(4);
        fresh.set_wrap_mode(WrapMode::WordOrGlyph);
        fresh.set_style(self.theme.input_text);
        fresh.set_placeholder_text("Type a message or /help for commands...");
        fresh.set_placeholder_style(
            // WCAG AA: Rgb(135,135,148) L=0.22 CR=5.0:1 vs black; ITALIC has no luminance effect.
            Style::default()
                .fg(Color::Rgb(135, 135, 148))
                .add_modifier(Modifier::ITALIC),
        );
        fresh.set_block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(self.theme.input_border)
                .title(self.input_panel_title(&self.theme.prompt_symbol)),
        );
        fresh
    }

    /// Extract the current animation frame index from the active `DisplayState`.
    ///
    /// Used by `render_input` to synchronise the input-box waiting title with
    /// the status bar spinner — both consumers read the same frame counter so
    /// the braille animation is perfectly in step.
    ///
    /// Returns 0 (⠋) when the state has no frame (e.g. `Idle`).
    fn current_spinner_frame(&self) -> usize {
        match &self.display_state {
            DisplayState::AwaitingFirstToken { frame, .. }
            | DisplayState::Thinking { frame, .. }
            | DisplayState::ToolExec { frame, .. }
            | DisplayState::BgOp { frame, .. } => *frame,
            _ => 0,
        }
    }

    fn input_panel_title(&self, prompt_symbol: &str) -> String {
        let mut title = self.editor_mode.input_title(prompt_symbol);
        if matches!(self.editor_mode, InputEditorMode::ComposeNormal)
            && let Some(pending) = self.vim_pending
        {
            title.pop();
            title.push_str(pending.input_title_suffix());
            title.push(' ');
        }
        title
    }

    fn input_area_height_for_area(&mut self, area: Rect) -> u16 {
        let max_input_height = if self.editor_mode.is_compose() {
            area.height.saturating_sub(6).clamp(6, 16)
        } else {
            10
        };
        let min_input_height = if self.editor_mode.is_compose() { 5 } else { 3 };
        self.textarea
            .measure(area.width.max(3))
            .preferred_rows
            .clamp(min_input_height, max_input_height)
    }

    fn set_editor_mode(&mut self, mode: InputEditorMode) {
        self.editor_mode = mode;
        self.vim_pending = None;
        self.apply_textarea_editor_style();
        self.needs_redraw = true;
    }

    fn set_keyboard_enhancement_enabled(&mut self, enabled: bool) {
        self.keyboard_enhancement_enabled = enabled;
        self.needs_redraw = true;
    }

    fn inline_compose_hint(&self) -> &'static str {
        if self.keyboard_enhancement_enabled {
            "Shift+Enter=compose"
        } else {
            "Ctrl+O=compose"
        }
    }

    fn paging_key_hint_label(&self) -> &'static str {
        if self.direct_paging_keys_supported {
            "PgUp/Dn"
        } else {
            "^B/^F"
        }
    }

    fn paging_key_help_span(&self, accent: Color) -> Span<'static> {
        Span::styled(
            format!("{} ", self.paging_key_hint_label()),
            Style::default().fg(accent),
        )
    }

    fn tab_key_help_span(&self, accent: Color) -> Span<'static> {
        Span::styled("Tab ", Style::default().fg(accent))
    }

    fn focus_pane_help_spans(&self, accent: Color) -> [Span<'static>; 2] {
        [
            self.tab_key_help_span(accent),
            Span::styled("focus pane  ", Style::default().fg(Color::DarkGray)),
        ]
    }

    fn page_or_scroll_help_spans(&self, accent: Color) -> [Span<'static>; 2] {
        [
            self.paging_key_help_span(accent),
            Span::styled("page or scroll  ", Style::default().fg(Color::DarkGray)),
        ]
    }

    fn paging_scroll_hint(&self, scroll: u16, ascii: bool) -> String {
        if ascii {
            format!(" scroll:{scroll} ^G=end {} ", self.paging_key_hint_label())
        } else {
            format!(" ↑{scroll}  ^G=end  {} ", self.paging_key_hint_label())
        }
    }

    fn paging_keyboard_note(&self) -> &'static str {
        if self.direct_paging_keys_supported {
            "If PageUp / PageDown do not reach EdgeCrab, use Ctrl+B / Ctrl+F."
        } else {
            "Use Ctrl+B / Ctrl+F to page through history and detail panes."
        }
    }

    fn normalize_paging_help_line(&self, mut line: Line<'static>) -> Line<'static> {
        let label = self.paging_key_hint_label();
        for span in &mut line.spans {
            if span.content == "PgUp/Dn " {
                span.content = Cow::Owned(format!("{label} "));
            }
        }
        line
    }

    fn compose_normal_hint(&self) -> String {
        if let Some(pending) = self.vim_pending {
            pending.status_hint()
        } else {
            format!(
                " NORMAL  hjkl/←↓↑→  i/a/o/O C/S/s Y D  {}=input  ^S=send  Esc=inline ",
                self.paging_key_hint_label()
            )
        }
    }

    fn enter_compose_insert(&mut self) {
        if !matches!(self.editor_mode, InputEditorMode::ComposeInsert) {
            self.set_editor_mode(InputEditorMode::ComposeInsert);
        } else {
            self.vim_pending = None;
        }
    }

    fn enter_compose_normal(&mut self) {
        self.set_editor_mode(InputEditorMode::ComposeNormal);
    }

    fn exit_compose_mode(&mut self) {
        self.set_editor_mode(InputEditorMode::Inline);
    }

    fn submit_current_input(&mut self) {
        let input = self.textarea_text().trim().to_string();
        if !input.is_empty() {
            self.push_history(&input);
            self.process_input(&input);
            self.scroll_offset = 0;
            self.at_bottom = true;
        }
        self.exit_compose_mode();
        self.textarea_clear();
        self.completion.active = false;
    }

    /// Clear the textarea.
    fn textarea_clear(&mut self) {
        self.textarea = self.fresh_textarea();
        self.apply_textarea_editor_style();
    }

    /// Clear the textarea and set it to `text`.
    ///
    /// Single responsibility helper that eliminates the repeated
    /// `textarea_clear()` + `for ch in … { insert_char(ch) }` pattern that
    /// would otherwise appear at every call site.
    fn textarea_set_text(&mut self, text: &str) {
        self.textarea_clear();
        if !text.is_empty() {
            self.textarea.insert_str(text);
        }
    }

    /// Dispatch a prompt received before TUI starts (e.g. from CLI args).
    pub fn dispatch_initial_prompt(&mut self, prompt: String) {
        self.push_output(format!("> {}", prompt), OutputRole::User);
        self.process_input(&prompt);
    }

    // ─── Skills management helpers ──────────────────────────────────

    /// Return the path to the skills directory.
    ///
    /// Uses `~/.edgecrab/skills/` — the canonical skills location, matching
    /// the skills tools which use `ctx.config.edgecrab_home.join("skills")`.
    fn skills_dir() -> std::path::PathBuf {
        edgecrab_core::edgecrab_home().join("skills")
    }

    /// Find a skill's SKILL.md by name, searching recursively through category
    /// subdirectories.  Returns the path to SKILL.md, or None.
    ///
    /// 1. Direct flat lookup: `skills/<name>/SKILL.md` or `skills/<name>.md`
    /// 2. Recursive search: find a directory whose leaf name matches `name`
    /// 3. Recursive search for `<name>.md` files under category directories
    fn find_skill_md(name: &str) -> Option<std::path::PathBuf> {
        let skills_dir = Self::skills_dir();

        // 1. Direct flat lookup
        let direct = skills_dir.join(name).join("SKILL.md");
        if direct.is_file() {
            return Some(direct);
        }
        let direct_md = if name.ends_with(".md") {
            skills_dir.join(name)
        } else {
            skills_dir.join(format!("{name}.md"))
        };
        if direct_md.is_file() {
            return Some(direct_md);
        }

        // 2. Recursive search by leaf directory name or file stem
        let mut stack = vec![skills_dir];
        while let Some(dir) = stack.pop() {
            let entries = match std::fs::read_dir(&dir) {
                Ok(e) => e,
                Err(_) => continue,
            };
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() {
                    if let Some(leaf) = path.file_name().and_then(|n| n.to_str())
                        && leaf == name
                    {
                        let md = path.join("SKILL.md");
                        if md.is_file() {
                            return Some(md);
                        }
                    }
                    stack.push(path);
                } else if path.is_file()
                    && path
                        .extension()
                        .and_then(|ext| ext.to_str())
                        .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
                    && path.file_stem().and_then(|stem| stem.to_str()) == Some(name)
                {
                    return Some(path);
                }
            }
        }

        None
    }

    fn read_skill_desc(path: &std::path::Path) -> String {
        let content = std::fs::read_to_string(path).unwrap_or_default();
        edgecrab_core::extract_skill_description(&content)
            .map(|d| unicode_trunc(&d, 80))
            .or_else(|| {
                Self::read_skill_preview(&content)
                    .lines()
                    .next()
                    .map(str::to_string)
            })
            .unwrap_or_default()
    }

    fn read_skill_preview(content: &str) -> String {
        let mut lines = Vec::new();
        for raw in edgecrab_core::prompt_builder::strip_yaml_frontmatter(content).lines() {
            let trimmed = raw.trim();
            if trimmed.is_empty() {
                continue;
            }
            let normalized = trimmed
                .trim_start_matches('#')
                .trim_start_matches('-')
                .trim_start_matches('*')
                .trim();
            if normalized.is_empty() {
                continue;
            }
            lines.push(unicode_trunc(normalized, 96).to_string());
            if lines.len() == 5 {
                break;
            }
        }
        lines.join("\n")
    }

    fn build_skill_entry(
        skills_root: &std::path::Path,
        skill_path: &std::path::Path,
        is_dir: bool,
        active_skills: &HashSet<String>,
    ) -> Option<SkillEntry> {
        let (name, content_path) = if is_dir {
            (
                skill_path.file_name()?.to_str()?.to_string(),
                skill_path.join("SKILL.md"),
            )
        } else {
            (
                skill_path.file_stem()?.to_str()?.to_string(),
                skill_path.to_path_buf(),
            )
        };
        let content = std::fs::read_to_string(&content_path).unwrap_or_default();
        let display_name =
            edgecrab_core::extract_frontmatter_name(&content).unwrap_or_else(|| name.clone());
        let desc = Self::read_skill_desc(&content_path);
        let preview = Self::read_skill_preview(&content);
        let relative = skill_path
            .strip_prefix(skills_root)
            .unwrap_or(skill_path)
            .to_string_lossy()
            .replace('\\', "/");
        let category = skill_path
            .strip_prefix(skills_root)
            .ok()
            .and_then(|path| path.parent())
            .map(|parent| parent.to_string_lossy().replace('\\', "/"))
            .filter(|value| !value.is_empty())
            .unwrap_or_else(|| "uncategorized".to_string());
        let active = active_skills.contains(&name);

        let mut support_sections = Vec::new();
        let mut support_files = 0usize;
        if is_dir {
            if let Ok(read_dir) = std::fs::read_dir(skill_path) {
                for entry in read_dir.flatten() {
                    let path = entry.path();
                    let file_name = entry.file_name().to_string_lossy().to_string();
                    if file_name == "SKILL.md" || file_name.starts_with('.') {
                        continue;
                    }
                    support_files += 1;
                    if path.is_dir() {
                        support_sections.push(file_name);
                    }
                }
            }
            support_sections.sort();
        }

        let mut detail_parts = vec![category.clone()];
        if active {
            detail_parts.push("active".into());
        }
        if support_files > 0 {
            detail_parts.push(format!("{support_files} extra file(s)"));
        }
        let detail = detail_parts.join(" | ");

        let mut detail_view = vec![
            format!(
                "State: {}",
                if active {
                    "active for the next prompt"
                } else {
                    "available"
                }
            ),
            format!(
                "Kind: {}",
                if is_dir {
                    "skill bundle"
                } else {
                    "single markdown skill"
                }
            ),
            format!("Category: {category}"),
            format!("Path: {relative}"),
        ];
        if display_name != name {
            detail_view.push(format!("Frontmatter name: {display_name}"));
        }
        if !desc.is_empty() {
            detail_view.push(String::new());
            detail_view.push(desc.clone());
        }
        if support_files > 0 {
            detail_view.push(String::new());
            detail_view.push(format!("Supporting files: {support_files}"));
            if !support_sections.is_empty() {
                detail_view.push(format!("Support folders: {}", support_sections.join(", ")));
            }
        }
        if !preview.is_empty() {
            detail_view.push(String::new());
            detail_view.push("Preview:".into());
            for line in preview.lines() {
                detail_view.push(format!("  {line}"));
            }
        }

        let mut search_fields = vec![
            name.clone(),
            display_name.clone(),
            category.clone(),
            relative.clone(),
            desc.clone(),
            preview.replace('\n', " "),
        ];
        if active {
            search_fields.push("active".into());
        }
        search_fields.extend(support_sections.iter().cloned());

        Some(SkillEntry {
            name,
            display_name,
            is_dir,
            active,
            category,
            relative_path: relative,
            desc,
            detail,
            detail_view: detail_view.join("\n"),
            search_text: search_fields.join(" "),
        })
    }

    /// Reload the skills list from disk into `skill_selector` and
    /// `skills_completion_names`.  Called on startup and when the overlay opens.
    ///
    /// Recursively scans all directories under `skills_dir()` that contain a
    /// `SKILL.md` file, including category subdirectories (e.g.
    /// `skills/media/gif-search/SKILL.md`).  Uses the canonical frontmatter
    /// parser from `edgecrab-core` to extract names and descriptions.
    fn refresh_skills_list(&mut self) {
        let dir = Self::skills_dir();
        let mut entries: Vec<SkillEntry> = Vec::new();
        let active_skills: HashSet<String> = self.active_skills.iter().cloned().collect();

        if !dir.is_dir() {
            self.skills_completion_names.clear();
            self.skill_selector
                .replace_items_preserving_state(Vec::new());
            return;
        }

        // Recursive scan: walk category directories for both SKILL.md bundles and
        // standalone markdown skills.
        let mut stack = vec![dir.clone()];
        while let Some(current) = stack.pop() {
            let read_dir = match std::fs::read_dir(&current) {
                Ok(rd) => rd,
                Err(_) => continue,
            };
            for res in read_dir.flatten() {
                let path = res.path();
                if !path.is_dir() {
                    let file_name = res.file_name().to_string_lossy().to_string();
                    if file_name.starts_with('.')
                        || file_name == "SKILL.md"
                        || !path
                            .extension()
                            .and_then(|ext| ext.to_str())
                            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
                    {
                        continue;
                    }
                    if let Some(entry) = Self::build_skill_entry(&dir, &path, false, &active_skills)
                    {
                        entries.push(entry);
                    }
                    continue;
                }
                // Skip hidden/system dirs
                let dir_name = res.file_name().to_string_lossy().to_string();
                if dir_name.starts_with('.') {
                    continue;
                }
                let skill_md = path.join("SKILL.md");
                if skill_md.is_file() {
                    if let Some(entry) = Self::build_skill_entry(&dir, &path, true, &active_skills)
                    {
                        entries.push(entry);
                    }
                } else {
                    // Not a skill — might be a category dir, recurse
                    stack.push(path);
                }
            }
        }

        entries.sort_by(|left, right| {
            right
                .active
                .cmp(&left.active)
                .then_with(|| left.name.cmp(&right.name))
        });

        // Update completion names cache (plain names, no slash prefix)
        self.skills_completion_names = entries.iter().map(|e| e.name.clone()).collect();

        self.skill_selector.replace_items_preserving_state(entries);
    }

    fn open_skill_selector(&mut self) {
        self.refresh_skills_list();
        self.reset_split_detail_scroll(DetailSurface::SkillSelector);
        self.skill_selector.activate();
    }

    fn build_profile_entry(summary: ProfileSummary) -> ProfileEntry {
        let detail = format!(
            "{} | skills={} | sessions={} | env={} | gw={}",
            summary.model,
            summary.skill_count,
            summary.session_count,
            if summary.has_env { "yes" } else { "no" },
            if summary.gateway_running { "on" } else { "off" }
        );
        let alias = summary
            .alias_path
            .as_ref()
            .map(|path| path.display().to_string())
            .unwrap_or_else(|| "none".into());
        let detail_view = format!(
            "Profile:  {}\nKind:     {}\nState:    {}\nHome:     {}\nModel:    {}\nSkills:   {}\nPlugins:  {}\nHooks:    {}\nSessions: {}\nGateway:  {}\nEnv:      {}\nSOUL:     {}\nHoncho:   {}\nMCP:      {} enabled\nAlias:    {}\nDisk:     {} MB",
            summary.name,
            summary.kind_label(),
            summary.state_label(),
            summary.home.display(),
            summary.model,
            summary.skill_count,
            summary.plugin_count,
            summary.hook_count,
            summary.session_count,
            if summary.gateway_running {
                "running"
            } else {
                "stopped"
            },
            if summary.has_env {
                "present"
            } else {
                "missing"
            },
            if summary.soul_present {
                "present"
            } else {
                "missing"
            },
            summary.honcho_label(),
            summary.enabled_mcp_servers,
            alias,
            summary.disk_mb
        );
        let search_text = format!(
            "{} {} {} {} {} {} {} {} {}",
            summary.name,
            summary.kind_label(),
            summary.state_label(),
            summary.model,
            summary.home.display(),
            detail,
            if summary.has_env { "env" } else { "" },
            if summary.gateway_running {
                "gateway"
            } else {
                ""
            },
            alias
        );
        ProfileEntry {
            name: summary.name,
            is_default: summary.is_default,
            is_active: summary.is_active,
            gateway_running: summary.gateway_running,
            model: summary.model,
            detail,
            detail_view,
            search_text,
        }
    }

    fn refresh_profiles_list(&mut self) {
        let manager = ProfileManager::new();
        let entries = manager
            .summaries()
            .map(|summaries| {
                let mut entries: Vec<ProfileEntry> = summaries
                    .into_iter()
                    .map(Self::build_profile_entry)
                    .collect();
                entries.sort_by(|left, right| {
                    right
                        .is_active
                        .cmp(&left.is_active)
                        .then_with(|| left.name.cmp(&right.name))
                });
                entries
            })
            .unwrap_or_default();
        self.profile_selector
            .replace_items_preserving_state(entries);
    }

    fn focus_profile_selector_item(&mut self, profile_name: Option<&str>) {
        if let Some(name) = profile_name {
            self.profile_selector.activate_with_primary(name);
        } else {
            self.profile_selector.activate();
        }
    }

    fn set_profile_detail_mode(&mut self, mode: ProfileDetailMode) {
        self.profile_detail_mode = mode;
        self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
        self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
        self.needs_redraw = true;
    }

    fn cycle_profile_detail_mode(&mut self, forward: bool) {
        let mode = if forward {
            self.profile_detail_mode.next()
        } else {
            self.profile_detail_mode.previous()
        };
        self.set_profile_detail_mode(mode);
    }

    fn open_profile_selector_with(
        &mut self,
        profile_name: Option<&str>,
        detail_mode: ProfileDetailMode,
    ) {
        self.refresh_profiles_list();
        self.profile_detail_mode = detail_mode;
        self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
        self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
        self.focus_profile_selector_item(profile_name);
        self.needs_redraw = true;
    }

    fn open_profile_selector(&mut self) {
        self.open_profile_selector_with(None, ProfileDetailMode::Summary);
    }

    fn open_document_overlay(
        &mut self,
        title: impl Into<String>,
        subtitle: impl Into<String>,
        body: impl Into<String>,
        icon: impl Into<String>,
        accent: Color,
    ) {
        self.document_overlay = Some(DocumentOverlayState {
            title: title.into(),
            subtitle: subtitle.into(),
            body: body.into(),
            icon: icon.into(),
            accent,
            scroll: 0,
        });
        self.needs_redraw = true;
    }

    fn close_document_overlay(&mut self) {
        self.document_overlay = None;
        self.needs_redraw = true;
    }

    fn scroll_document_overlay(&mut self, delta: i16) {
        let Some(overlay) = self.document_overlay.as_mut() else {
            return;
        };
        overlay.scroll = if delta.is_negative() {
            overlay.scroll.saturating_sub(delta.unsigned_abs())
        } else {
            overlay.scroll.saturating_add(delta as u16)
        };
        self.needs_redraw = true;
    }

    fn set_document_overlay_scroll(&mut self, scroll: u16) {
        if let Some(overlay) = self.document_overlay.as_mut() {
            overlay.scroll = scroll;
            self.needs_redraw = true;
        }
    }

    fn open_report_overlay(
        &mut self,
        title: impl Into<String>,
        subtitle: impl Into<String>,
        body: impl Into<String>,
    ) {
        self.open_document_overlay(title, subtitle, body, "≡", Color::Rgb(130, 210, 255));
    }

    fn normalize_skill_identifier(identifier: &str) -> String {
        identifier.trim().replace('\\', "/")
    }

    fn build_remote_skill_entries(
        report: &edgecrab_tools::tools::skills_hub::SearchReport,
    ) -> (Vec<RemoteSkillEntry>, Vec<String>) {
        let skills_dir = Self::skills_dir();
        let lock = edgecrab_tools::tools::skills_hub::read_lock();
        let mut installed_by_identifier = HashMap::new();
        for (installed_name, entry) in lock {
            installed_by_identifier.insert(
                Self::normalize_skill_identifier(&entry.identifier),
                installed_name,
            );
        }

        let mut entries = Vec::new();
        let mut notices = Vec::new();
        for group in &report.groups {
            if let Some(notice) = &group.notice {
                notices.push(format!("{}: {}", group.source.label, notice));
            }

            for skill in &group.results {
                let normalized_identifier = Self::normalize_skill_identifier(&skill.identifier);
                let installed_name = installed_by_identifier.get(&normalized_identifier).cloned();
                let has_local_collision = Self::find_skill_md(&skill.name).is_some()
                    || skills_dir.join(&skill.name).exists();
                let action = if installed_name.is_some() {
                    RemoteSkillAction::Update
                } else if has_local_collision {
                    RemoteSkillAction::Replace
                } else {
                    RemoteSkillAction::Install
                };
                let description = if skill.description.trim().is_empty() {
                    "No description available".to_string()
                } else {
                    skill.description.trim().to_string()
                };
                let search_text = format!(
                    "{} {} {} {} {} {}",
                    skill.name,
                    normalized_identifier,
                    description,
                    skill.origin,
                    skill.trust_level,
                    skill.tags.join(" ")
                );

                entries.push(RemoteSkillEntry {
                    name: skill.name.clone(),
                    identifier: normalized_identifier,
                    description,
                    source_label: group.source.label.clone(),
                    origin: skill.origin.clone(),
                    trust_level: skill.trust_level.clone(),
                    tags: skill.tags.clone(),
                    search_text,
                    installed_name,
                    action,
                });
            }
        }

        (entries, notices)
    }

    fn build_remote_plugin_entries(
        report: &edgecrab_plugins::PluginSearchReport,
    ) -> (Vec<RemotePluginEntry>, Vec<String>) {
        let mut manager = crate::plugins::PluginManager::new();
        manager.discover_all();
        let mut installed_by_source = HashMap::new();
        let mut installed_by_name = HashMap::new();
        for plugin in manager.plugins() {
            installed_by_name.insert(plugin.name.clone(), plugin.name.clone());
            if let Some(source) = &plugin.install_source {
                installed_by_source.insert(source.clone(), plugin.name.clone());
            }
        }

        let mut entries = Vec::new();
        let mut notices = Vec::new();
        for group in &report.groups {
            if let Some(notice) = &group.notice {
                notices.push(format!("{}: {}", group.source.label, notice));
            }

            for plugin in &group.results {
                let installed_name = installed_by_source.get(&plugin.identifier).cloned();
                let action = if installed_name.is_some() {
                    RemotePluginAction::Update
                } else if installed_by_name.contains_key(&plugin.name) {
                    RemotePluginAction::Replace
                } else {
                    RemotePluginAction::Install
                };
                let description = if plugin.description.trim().is_empty() {
                    "No description available".to_string()
                } else {
                    plugin.description.trim().to_string()
                };
                let search_text = format!(
                    "{} {} {} {} {} {} {}",
                    plugin.name,
                    plugin.identifier,
                    description,
                    plugin.origin,
                    plugin.kind.as_tag(),
                    plugin.trust_level,
                    plugin.tags.join(" ")
                );
                entries.push(RemotePluginEntry {
                    name: plugin.name.clone(),
                    identifier: plugin.identifier.clone(),
                    description,
                    source_label: group.source.label.clone(),
                    origin: plugin.origin.clone(),
                    trust_level: plugin.trust_level.clone(),
                    kind: plugin.kind.as_tag().to_string(),
                    tags: plugin.tags.clone(),
                    requires_env: plugin.requires_env.clone(),
                    installed_name,
                    action,
                    search_text,
                });
            }
        }

        (entries, notices)
    }

    fn build_remote_mcp_entries(
        report: &crate::mcp_catalog::McpSearchReport,
    ) -> (Vec<RemoteMcpEntry>, Vec<String>) {
        let mut entries = Vec::new();
        let mut notices = Vec::new();

        for group in &report.groups {
            if let Some(notice) = &group.notice {
                notices.push(format!("{}: {}", group.source.label, notice));
            }

            for entry in &group.results {
                let description = if entry.description.trim().is_empty() {
                    "No description available".to_string()
                } else {
                    entry.description.trim().to_string()
                };
                let transport = entry.transport.clone();
                let search_text = format!(
                    "{} {} {} {} {} {}",
                    entry.id,
                    entry.name,
                    description,
                    entry.origin,
                    transport.clone().unwrap_or_default(),
                    entry.tags.join(" ")
                );

                entries.push(RemoteMcpEntry {
                    id: entry.id.clone(),
                    name: entry.name.clone(),
                    description,
                    source_label: group.source.label.clone(),
                    origin: entry.origin.clone(),
                    tags: entry.tags.clone(),
                    transport,
                    search_text,
                    install: entry.install.clone(),
                });
            }
        }

        (entries, notices)
    }

    fn open_remote_mcp_selector(&mut self, initial_query: Option<&str>) {
        self.mcp_selector.active = false;
        self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
        self.remote_mcp_browser.activate(initial_query, None);
        self.schedule_remote_mcp_search(true);
        self.needs_redraw = true;
    }

    fn schedule_remote_mcp_search(&mut self, immediate: bool) {
        let query = self.remote_mcp_browser.current_query();
        if query.is_empty() {
            self.remote_mcp_browser.reset_results();
            self.needs_redraw = true;
            return;
        }

        self.remote_mcp_browser.search_due_at = Some(if immediate {
            Instant::now()
        } else {
            Instant::now() + Duration::from_millis(250)
        });
        self.needs_redraw = true;
    }

    fn poll_remote_mcp_search(&mut self) {
        if !self.remote_mcp_browser.selector.active {
            return;
        }

        let Some(deadline) = self.remote_mcp_browser.search_due_at else {
            return;
        };
        if Instant::now() < deadline {
            return;
        }

        let query = self.remote_mcp_browser.current_query();
        self.remote_mcp_browser.search_due_at = None;
        if query.is_empty() {
            return;
        }
        if self.remote_mcp_browser.loading_query.as_deref() == Some(query.as_str()) {
            return;
        }

        self.remote_mcp_browser.next_request_id =
            self.remote_mcp_browser.next_request_id.saturating_add(1);
        let request_id = self.remote_mcp_browser.next_request_id;
        self.remote_mcp_browser.inflight_request_id = Some(request_id);
        self.remote_mcp_browser.loading_query = Some(query.clone());
        let tx = self.response_tx.clone();
        self.rt_handle.spawn(async move {
            let report = crate::mcp_catalog::search_mcp_sources(Some(&query), 12).await;
            let _ = tx.send(AgentResponse::RemoteMcpSearchReady {
                request_id,
                query,
                report,
            });
        });
        self.needs_redraw = true;
    }

    fn apply_remote_mcp_search_result(
        &mut self,
        request_id: u64,
        query: String,
        report: crate::mcp_catalog::McpSearchReport,
    ) {
        if self.remote_mcp_browser.inflight_request_id != Some(request_id) {
            return;
        }
        if self.remote_mcp_browser.current_query() != query {
            return;
        }

        let (entries, notices) = Self::build_remote_mcp_entries(&report);
        self.remote_mcp_browser.inflight_request_id = None;
        self.remote_mcp_browser.loading_query = None;
        self.remote_mcp_browser.last_completed_query = Some(query);
        self.remote_mcp_browser.notices = notices;
        self.remote_mcp_browser.selector.set_items(entries);
        self.remote_mcp_browser.selector.selected = 0;
        self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
        self.needs_redraw = true;
    }

    fn view_remote_mcp_entry(&mut self, entry: &RemoteMcpEntry) {
        let mut lines = vec![
            format!("Remote MCP: {}", entry.id),
            format!("Name:       {}", entry.name),
            format!("Source:     {}", entry.source_label),
            format!("Why:        {}", entry.description),
            format!("Origin:     {}", entry.origin),
        ];
        if let Some(transport) = &entry.transport {
            lines.push(format!("Transport:  {transport}"));
        }
        if !entry.tags.is_empty() {
            lines.push(format!("Tags:       {}", entry.tags.join(", ")));
        }
        let install = match &entry.install {
            Some(crate::mcp_catalog::McpInstallPlan::Preset { preset_id }) => {
                format!("preset {preset_id}")
            }
            Some(crate::mcp_catalog::McpInstallPlan::Http { url, transport, .. }) => {
                format!("{transport} {url}")
            }
            Some(crate::mcp_catalog::McpInstallPlan::Stdio { command, args, .. }) => {
                format!("stdio {} {}", command, args.join(" "))
            }
            None => "view-only".into(),
        };
        lines.push(format!("Install:    {install}"));
        self.push_output(lines.join("\n"), OutputRole::System);
    }

    fn install_remote_mcp_entry(&mut self, entry: &RemoteMcpEntry) {
        let Some(install) = entry.install.clone() else {
            self.view_remote_mcp_entry(entry);
            return;
        };

        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        let mut config = self.load_runtime_config();
        let search_entry = crate::mcp_catalog::McpSearchEntry {
            id: entry.id.clone(),
            name: entry.name.clone(),
            description: entry.description.clone(),
            source: entry.source_label.clone(),
            origin: entry.origin.clone(),
            homepage: None,
            tags: entry.tags.clone(),
            transport: entry.transport.clone(),
            install: Some(install),
        };

        match crate::mcp_catalog::install_search_entry(&mut config, &search_entry, &cwd) {
            Ok(installed) => match config.save() {
                Ok(()) => {
                    edgecrab_tools::tools::mcp_client::reload_mcp_connections();
                    let mut message = format!("Configured MCP server '{}'.", installed.name);
                    if !installed.warnings.is_empty() {
                        message.push_str(&format!(
                            "\nFollow-up required: {}",
                            installed.warnings.join(", ")
                        ));
                    }
                    message.push_str(&format!(
                        "\nRun `/mcp doctor {}` to verify connectivity and config health.",
                        installed.name
                    ));
                    self.push_output(message, OutputRole::System);
                }
                Err(err) => self.push_output(
                    format!("MCP config save failed after install planning: {err}"),
                    OutputRole::Error,
                ),
            },
            Err(err) => self.push_output(format!("MCP install failed: {err}"), OutputRole::Error),
        }
    }

    fn open_remote_skill_selector(&mut self, initial_query: Option<&str>) {
        self.skill_selector.active = false;
        self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
        self.remote_skill_browser.activate(initial_query, None);
        self.schedule_remote_skill_search(true);
        self.needs_redraw = true;
    }

    fn schedule_remote_skill_search(&mut self, immediate: bool) {
        let query = self.remote_skill_browser.current_query();
        if query.is_empty() {
            self.remote_skill_browser.reset_results();
            self.needs_redraw = true;
            return;
        }

        self.remote_skill_browser.search_due_at = Some(if immediate {
            Instant::now()
        } else {
            Instant::now() + Duration::from_millis(250)
        });
        self.needs_redraw = true;
    }

    fn poll_remote_skill_search(&mut self) {
        if !self.remote_skill_browser.selector.active {
            return;
        }

        let Some(deadline) = self.remote_skill_browser.search_due_at else {
            return;
        };
        if Instant::now() < deadline {
            return;
        }

        let query = self.remote_skill_browser.current_query();
        self.remote_skill_browser.search_due_at = None;
        if query.is_empty() {
            return;
        }
        if self.remote_skill_browser.loading_query.as_deref() == Some(query.as_str()) {
            return;
        }

        self.remote_skill_browser.next_request_id =
            self.remote_skill_browser.next_request_id.saturating_add(1);
        let request_id = self.remote_skill_browser.next_request_id;
        self.remote_skill_browser.inflight_request_id = Some(request_id);
        self.remote_skill_browser.loading_query = Some(query.clone());
        let tx = self.response_tx.clone();
        self.rt_handle.spawn(async move {
            let report = edgecrab_tools::tools::skills_hub::search_hub(&query, None, 12).await;
            let _ = tx.send(AgentResponse::RemoteSkillSearchReady {
                request_id,
                query,
                report,
            });
        });
        self.needs_redraw = true;
    }

    fn open_remote_plugin_selector(
        &mut self,
        initial_query: Option<&str>,
        source_filter: Option<&str>,
    ) {
        self.plugin_toggle.active = false;
        self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
        self.remote_plugin_browser
            .activate(initial_query, source_filter);
        self.schedule_remote_plugin_search(true);
        self.needs_redraw = true;
    }

    fn schedule_remote_plugin_search(&mut self, immediate: bool) {
        let query = self.remote_plugin_browser.current_query();
        if query.is_empty() {
            self.remote_plugin_browser.reset_results();
            self.needs_redraw = true;
            return;
        }

        self.remote_plugin_browser.search_due_at = Some(if immediate {
            Instant::now()
        } else {
            Instant::now() + Duration::from_millis(250)
        });
        self.needs_redraw = true;
    }

    fn poll_remote_plugin_search(&mut self) {
        if !self.remote_plugin_browser.selector.active {
            return;
        }

        let Some(deadline) = self.remote_plugin_browser.search_due_at else {
            return;
        };
        if Instant::now() < deadline {
            return;
        }

        let query = self.remote_plugin_browser.current_query();
        self.remote_plugin_browser.search_due_at = None;
        if query.is_empty() {
            return;
        }
        if self.remote_plugin_browser.loading_query.as_deref() == Some(query.as_str()) {
            return;
        }

        self.remote_plugin_browser.next_request_id =
            self.remote_plugin_browser.next_request_id.saturating_add(1);
        let request_id = self.remote_plugin_browser.next_request_id;
        let source_filter = self.remote_plugin_browser.source_filter.clone();
        self.remote_plugin_browser.inflight_request_id = Some(request_id);
        self.remote_plugin_browser.loading_query = Some(query.clone());
        let tx = self.response_tx.clone();
        self.rt_handle.spawn(async move {
            let config_path = edgecrab_core::edgecrab_home().join("config.yaml");
            let config = edgecrab_core::AppConfig::load_from(&config_path).unwrap_or_default();
            let report = edgecrab_plugins::search_hub_report(
                &config.plugins,
                &query,
                source_filter.as_deref(),
                12,
            )
            .await
            .unwrap_or_default();
            let _ = tx.send(AgentResponse::RemotePluginSearchReady {
                request_id,
                query,
                report,
            });
        });
        self.needs_redraw = true;
    }

    fn apply_remote_plugin_search_result(
        &mut self,
        request_id: u64,
        query: String,
        report: edgecrab_plugins::PluginSearchReport,
    ) {
        if self.remote_plugin_browser.inflight_request_id != Some(request_id) {
            return;
        }
        if self.remote_plugin_browser.current_query() != query {
            return;
        }

        let (entries, notices) = Self::build_remote_plugin_entries(&report);
        self.remote_plugin_browser.inflight_request_id = None;
        self.remote_plugin_browser.loading_query = None;
        self.remote_plugin_browser.last_completed_query = Some(query);
        self.remote_plugin_browser.notices = notices;
        self.remote_plugin_browser.selector.set_items(entries);
        self.remote_plugin_browser.selector.selected = 0;
        self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
        self.needs_redraw = true;
    }

    fn run_remote_plugin_action(&mut self, entry: RemotePluginEntry) {
        if self.remote_plugin_browser.action_in_flight.is_some() {
            self.push_output(
                "A remote plugin action is already running. Wait for it to finish.",
                OutputRole::System,
            );
            return;
        }

        let action_label = entry.action.label().to_string();
        self.remote_plugin_browser.action_in_flight =
            Some(format!("{} {}", action_label, entry.identifier));
        self.needs_redraw = true;

        let tx = self.response_tx.clone();
        let action_identifier = entry.identifier.clone();
        self.rt_handle.spawn(async move {
            let result = match entry.action {
                RemotePluginAction::Install | RemotePluginAction::Replace => {
                    tokio::task::spawn_blocking(move || {
                        crate::plugins_cmd::install_plugin_capture(
                            &entry.identifier,
                            None,
                            false,
                            false,
                        )
                    })
                    .await
                    .map_err(|error| error.to_string())
                    .and_then(|result| result.map_err(|error| error.to_string()))
                }
                RemotePluginAction::Update => {
                    let plugin_name = entry
                        .installed_name
                        .clone()
                        .unwrap_or_else(|| entry.name.clone());
                    tokio::task::spawn_blocking(move || {
                        crate::plugins_cmd::update_plugin_capture(&plugin_name)
                    })
                    .await
                    .map_err(|error| error.to_string())
                    .and_then(|result| result.map_err(|error| error.to_string()))
                }
            };

            match result {
                Ok(outcome) => {
                    let _ = tx.send(AgentResponse::RemotePluginActionComplete {
                        message: outcome.message,
                        plugin_name: outcome.plugin_name,
                    });
                }
                Err(error) => {
                    let _ = tx.send(AgentResponse::RemotePluginActionFailed {
                        action_label,
                        identifier: action_identifier,
                        error,
                    });
                }
            }
        });
    }

    fn apply_remote_skill_search_result(
        &mut self,
        request_id: u64,
        query: String,
        report: edgecrab_tools::tools::skills_hub::SearchReport,
    ) {
        if self.remote_skill_browser.inflight_request_id != Some(request_id) {
            return;
        }
        if self.remote_skill_browser.current_query() != query {
            return;
        }

        let (entries, notices) = Self::build_remote_skill_entries(&report);
        self.remote_skill_browser.inflight_request_id = None;
        self.remote_skill_browser.loading_query = None;
        self.remote_skill_browser.last_completed_query = Some(query);
        self.remote_skill_browser.notices = notices;
        self.remote_skill_browser.selector.set_items(entries);
        self.remote_skill_browser.selector.selected = 0;
        self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
        self.needs_redraw = true;
    }

    fn run_remote_skill_action(&mut self, entry: RemoteSkillEntry) {
        if self.remote_skill_browser.action_in_flight.is_some() {
            self.push_output(
                "A remote skill action is already running. Wait for it to finish.",
                OutputRole::System,
            );
            return;
        }

        let action_label = entry.action.label().to_string();
        self.remote_skill_browser.action_in_flight =
            Some(format!("{} {}", action_label, entry.identifier));
        self.needs_redraw = true;

        let tx = self.response_tx.clone();
        self.rt_handle.spawn(async move {
            let skills_dir = edgecrab_core::edgecrab_home().join("skills");
            let optional_dir = edgecrab_tools::tools::skills_sync::optional_skills_dir();
            let result = match entry.action {
                RemoteSkillAction::Install | RemoteSkillAction::Replace => {
                    edgecrab_tools::tools::skills_hub::install_identifier(
                        &entry.identifier,
                        &skills_dir,
                        optional_dir.as_deref(),
                        false,
                    )
                    .await
                    .map(|outcome| (outcome.message, outcome.skill_name))
                }
                RemoteSkillAction::Update => {
                    let skill_name = entry
                        .installed_name
                        .clone()
                        .unwrap_or_else(|| entry.name.clone());
                    edgecrab_tools::tools::skills_hub::update_installed_skill(
                        &skill_name,
                        &skills_dir,
                        optional_dir.as_deref(),
                        false,
                    )
                    .await
                    .map(|outcome| (outcome.message, outcome.skill_name))
                }
            };

            match result {
                Ok((message, skill_name)) => {
                    let _ = tx.send(AgentResponse::RemoteSkillActionComplete {
                        message,
                        skill_name,
                    });
                }
                Err(error) => {
                    let _ = tx.send(AgentResponse::RemoteSkillActionFailed {
                        action_label,
                        identifier: entry.identifier,
                        error,
                    });
                }
            }
        });
    }

    // ─── Tab Completion ─────────────────────────────────────────────

    /// Static table of known argument / subcommand completions per command.
    ///
    /// **Design principles**
    /// - Completions match the *canonical* command name **and** all aliases  
    ///   so `/session`, `/sessions` and `/sess` (if it were an alias) all work.
    /// - Each entry is `(token, short_description)` shown in the overlay.
    /// - Commands that accept only free-form text (e.g. `/model <name>`,
    ///   `/save <path>`) return an empty slice so Tab falls through to the
    ///   history ghost-hint.
    /// - Single-char aliases (`/r`, `/u`, `/v`, …) are mapped to their
    ///   canonical names so Tab works regardless of which form the user typed.
    fn command_arg_hints(cmd_token: &str) -> &'static [(&'static str, &'static str)] {
        match cmd_token {
            // ── Session management ─────────────────────────────────────────────
            "session" => &[
                ("inspect", "Open the live current-session debugger"),
                ("debug", "Open the live current-session debugger"),
                ("browse", "Open the saved-session browser"),
                ("search", "Search saved sessions with a seeded query"),
                ("new", "Start a fresh session"),
                ("switch", "Activate a saved session: switch <id-prefix>"),
                ("delete", "Delete a saved session: delete <id-prefix>"),
                ("rename", "Rename: rename <id-prefix> <new title>"),
                ("prune", "Remove saved sessions older than N days"),
            ],
            "sessions" => &[
                ("list", "Open the session browser"),
                ("browse", "Open the session browser"),
                ("search", "Open the session browser with a query"),
                ("new", "Start a fresh session"),
                ("switch", "Activate a session: switch <id-prefix>"),
                ("delete", "Delete a session: delete <id-prefix>"),
                ("rename", "Rename: rename <id-prefix> <new title>"),
                ("prune", "Remove sessions older than N days (default 90)"),
            ],
            // ── Model / reasoning ──────────────────────────────────────────────
            "reasoning" | "think" => &[
                ("low", "Minimal reasoning — fastest, cheapest"),
                ("medium", "Balanced reasoning effort"),
                ("high", "Maximum reasoning depth — slowest"),
                ("show", "Display reasoning steps in the output pane"),
                ("hide", "Suppress reasoning steps from the output pane"),
                ("status", "Show the current think-mode state"),
            ],
            "stream" | "streaming" => &[
                ("on", "Enable live token streaming"),
                ("off", "Show the final answer only after completion"),
                ("toggle", "Flip the current streaming mode"),
                ("status", "Show the current streaming mode"),
            ],
            // ── Voice / TTS ────────────────────────────────────────────────────
            "voice" | "tts" => &[
                ("on", "Enable TTS — agent responses are read aloud"),
                ("off", "Disable TTS"),
                ("status", "Show current voice mode"),
                (
                    "continuous on",
                    "Enable hands-free continuous voice capture",
                ),
                ("continuous off", "Disable continuous voice capture"),
                ("stop", "Stop the active voice recording or continuous loop"),
                (
                    "doctor",
                    "Show recorder, permissions, and platform guidance",
                ),
                (
                    "record",
                    "Start or stop microphone capture and transcribe it",
                ),
                ("talk", "Start or stop push-to-talk capture and submit it"),
                ("tts", "Speak text immediately or enable auto-TTS"),
                ("transcribe", "Transcribe a local audio file"),
                ("reply", "Transcribe a local audio file and submit it"),
            ],
            // ── Mouse capture ──────────────────────────────────────────────────
            "mouse" => &[
                (
                    "on",
                    "Enable mouse capture (scroll wheel; disables native text selection)",
                ),
                ("off", "Disable mouse capture (free drag-to-copy; default)"),
                ("toggle", "Toggle current mouse mode"),
                ("status", "Show current mouse mode"),
            ],
            // ── Browser CDP ───────────────────────────────────────────────────
            "browser" => &[
                ("connect", "Connect to a running Chrome/Chromium instance"),
                ("disconnect", "Close the CDP connection"),
                ("status", "Show browser connection status"),
                ("tabs", "List open browser tabs"),
                ("recording", "Toggle recording: recording on | off"),
            ],
            // ── Cron scheduler ────────────────────────────────────────────────
            "cron" | "schedule" => &[
                ("list", "List all scheduled jobs"),
                ("add", "Add a job: add <cron-expr> <prompt>"),
                ("remove", "Remove a job: remove <id>"),
                ("run", "Run a job immediately: run <id>"),
                ("pause", "Pause a job: pause <id>"),
                ("resume", "Resume a paused job: resume <id>"),
                ("status", "Show scheduler status"),
            ],
            // ── MCP token management ──────────────────────────────────────────
            "mcp-token" => &[
                ("set", "Store a token: set <server-id> <token>"),
                (
                    "set-refresh",
                    "Store a refresh token: set-refresh <server-id> <token>",
                ),
                ("status", "Show cached token state for one server"),
                ("remove", "Delete a token: remove <server-id>"),
                ("list", "List stored server tokens"),
            ],
            "auth" => &[
                ("list", "List auth targets and local cache state"),
                ("status", "Show status for one target: status <target>"),
                ("add", "Store a token: add <target> --token <secret>"),
                ("login", "Run login/import for one target"),
                ("remove", "Delete one cached target"),
                ("reset", "Clear one target or all targets"),
            ],
            "login" => &[
                ("copilot", "Import the VS Code Copilot token"),
                ("provider/openai", "Provider env-backed token target"),
                (
                    "mcp/<server>",
                    "Run OAuth login for one configured MCP server",
                ),
            ],
            "logout" => &[
                ("copilot", "Clear the Copilot token cache"),
                (
                    "provider/openai",
                    "Remove one provider token from ~/.edgecrab/.env",
                ),
                ("mcp/<server>", "Remove one cached MCP token"),
            ],
            "webhook" => &[
                ("list", "List dynamic webhook subscriptions"),
                (
                    "subscribe",
                    "Create a route: subscribe <name> [--events push]",
                ),
                ("remove", "Delete a route: remove <name>"),
                ("test", "Send a signed test payload to a route"),
                ("path", "Show the subscriptions JSON path"),
            ],
            "uninstall" => &[
                ("--dry-run", "Preview the uninstall plan"),
                ("--purge-data", "Delete ~/.edgecrab as part of uninstall"),
                ("--purge-auth-cache", "Delete the local Copilot cache"),
                ("--remove-binary", "Delete the current edgecrab binary"),
                ("--yes", "Execute without interactive confirmation"),
            ],
            "mcp" => &[
                ("list", "List configured MCP servers"),
                ("refresh", "Refresh the official MCP catalog cache"),
                (
                    "search",
                    "Search official MCP sources + the official registry",
                ),
                ("view", "Show details for a preset"),
                ("install", "Install a controlled MCP preset"),
                ("test", "Probe a configured MCP server"),
                ("doctor", "Diagnose a configured MCP server"),
                ("auth", "Explain the active auth/OAuth path for a server"),
                ("login", "Run an interactive OAuth login for a server"),
                ("remove", "Remove a configured MCP server"),
            ],
            // ── Personality ───────────────────────────────────────────────────
            "personality" | "persona" => &[],
            // ── Appearance / skin ─────────────────────────────────────────────
            "theme" | "skin" => &[
                ("default", "Default (system) skin"),
                ("dracula", "Dracula dark theme"),
                ("solarized", "Solarized (light-friendly)"),
                ("nord", "Nord arctic theme"),
                ("monokai", "Monokai classic"),
                ("gruvbox", "Gruvbox retro theme"),
            ],
            // ── Models live discovery ─────────────────────────────────────────
            "models" => &[
                ("refresh", "Force-refresh model list from providers"),
                ("openai", "List OpenAI models"),
                ("anthropic", "List Anthropic models"),
                ("google", "List Google models"),
                ("copilot", "List GitHub Copilot models"),
                ("ollama", "List locally running Ollama models"),
            ],
            "cheap_model" | "cheap-model" => &[
                ("status", "Show the current cheap-model routing policy"),
                ("off", "Disable smart routing and clear the cheap model"),
                (
                    "copilot/gpt-4.1-mini",
                    "Route simple turns to a fast cheap model",
                ),
            ],
            "vision_model" | "vision-model" => &[
                ("status", "Show the current vision-routing policy"),
                ("auto", "Use the current chat model when vision-capable"),
                ("openai/gpt-4o", "Route image analysis to a dedicated model"),
                ("copilot/gpt-5.4", "Use Copilot's multimodal backend"),
            ],
            "image_model" | "image-model" => &[
                ("status", "Show the current image-generation default"),
                ("list", "Open the image-model selector"),
                ("default", "Reset to the built-in default image model"),
                (
                    "gemini/gemini-2.5-flash-image",
                    "Use the default cheap Gemini image model",
                ),
                (
                    "imagen/imagen-4.0-fast-generate-001",
                    "Use Vertex Imagen fast",
                ),
            ],
            "moa" => &[
                ("status", "Show the current Mixture-of-Agents defaults"),
                ("on", "Enable the moa tool"),
                ("off", "Disable the moa tool"),
                ("aggregator", "Open the MoA aggregator selector"),
                ("experts", "Open the full MoA expert roster editor"),
                ("references", "Open the full MoA expert roster editor"),
                ("reset", "Restore the built-in MoA roster"),
                ("add", "Add one expert to the MoA roster"),
                ("remove", "Remove one expert from the MoA roster"),
            ],
            // All other commands accept free-form arguments; fall through.
            _ => &[],
        }
    }

    /// Update completion candidates based on current input.
    ///
    /// **Two-level completion (first-principles design)**
    ///
    /// The input is parsed into two regions:
    ///
    /// ```text
    ///  /session        sw
    ///  └── cmd token ─┘└─ arg fragment ─┘
    ///  (byte 0)        (byte arg_start)
    /// ```
    ///
    /// *Argument context* (`text` contains a whitespace after the `/cmd`):
    ///   Completions are drawn from the static `command_arg_hints` table.
    ///   Only the arg fragment is matched; the cmd prefix is preserved verbatim.
    ///
    /// *Command context* (no whitespace yet):
    ///   Completions are drawn from the full command/skill name list.  Both
    ///   prefix-match and fuzzy-match (jaro-winkler) are used.
    fn update_completion(&mut self) {
        let text = self.textarea_text();
        if !text.starts_with('/') || text.contains('\n') {
            self.completion.active = false;
            return;
        }

        // ─── Argument / subcommand context ────────────────────────────────────
        if let Some(sp) = text.find(char::is_whitespace) {
            // cmd_token: "session" from "/session sw"
            let cmd_token = &text[1..sp];

            // Compute where the arg fragment starts (skip leading spaces after cmd).
            let after = &text[sp..]; // e.g. "  sw"
            let trimmed = after.trim_start(); // e.g. "sw"
            let leading_spaces = after.len() - trimmed.len();
            let arg_start = sp + leading_spaces;

            // If the fragment itself contains whitespace we are past the
            // first argument \u2014 no further static completions.
            if trimmed.contains(char::is_whitespace) {
                self.completion.active = false;
                return;
            }

            let hints: Vec<(String, String)> = if matches!(cmd_token, "personality" | "persona") {
                self.personality_arg_hints()
            } else {
                Self::command_arg_hints(cmd_token)
                    .iter()
                    .map(|(sub, desc)| ((*sub).to_string(), (*desc).to_string()))
                    .collect()
            };
            if hints.is_empty() {
                // If cmd_token is already an exact known command, free-form args follow —
                // nothing to complete.  But if it is only a *partial* token (e.g. "hel"
                // typed as "/hel some-query") we fall through to command-token completion
                // so the user can complete the command name while preserving the arg tail.
                let full_cmd = format!("/{cmd_token}");
                if self.all_command_names.contains(&full_cmd) {
                    self.completion.active = false;
                    return;
                }
                // Partial command with arg tail — fall through to command-token completion.
                // The command-token block below uses `text.trim()` as prefix which would be
                // the full input.  Instead we supply just the partial command prefix via
                // a `cmd_prefix_override`, avoiding the whitespace.
                let partial_prefix = format!("/{cmd_token}");
                self.completion.arg_start = 0; // accept_completion will preserve arg tail

                let desc_for = |name: &str| -> String {
                    self.command_descriptions
                        .get(name)
                        .cloned()
                        .unwrap_or_default()
                };
                let mut candidates: Vec<(String, String)> = self
                    .all_command_names
                    .iter()
                    .filter(|cmd| cmd.starts_with(&partial_prefix) && *cmd != &partial_prefix)
                    .map(|cmd| (cmd.clone(), desc_for(cmd)))
                    .collect();
                if candidates.is_empty() && partial_prefix.len() >= 2 {
                    let mut scored: Vec<(String, f64)> = self
                        .all_command_names
                        .iter()
                        .map(|cmd| (cmd.clone(), strsim::jaro_winkler(&partial_prefix, cmd)))
                        .filter(|(_, score)| *score > 0.7)
                        .collect();
                    scored
                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
                    candidates = scored
                        .into_iter()
                        .take(8)
                        .map(|(cmd, _)| (cmd.clone(), desc_for(&cmd)))
                        .collect();
                }
                if candidates.is_empty() {
                    self.completion.active = false;
                } else {
                    self.completion.candidates = candidates;
                    self.completion.selected = 0;
                    self.completion.active = true;
                }
                return;
            }

            // hints is non-empty — offer subcommand/arg completions.

            // Filter: prefix-match, then fuzzy fallback for typos.
            let mut candidates: Vec<(String, String)> = hints
                .iter()
                .filter(|(sub, _)| sub.starts_with(trimmed))
                .map(|(sub, desc)| (sub.clone(), desc.clone()))
                .collect();

            // Fuzzy fallback: if prefix match yielded nothing and user typed ≥2 chars.
            if candidates.is_empty() && trimmed.len() >= 2 {
                let mut scored: Vec<(String, f64)> = hints
                    .iter()
                    .map(|(sub, _)| (sub.clone(), strsim::jaro_winkler(trimmed, sub)))
                    .filter(|(_, score)| *score > 0.65)
                    .collect();
                scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
                candidates = scored
                    .into_iter()
                    .map(|(sub, _)| {
                        let d = hints
                            .iter()
                            .find(|(s, _)| *s == sub)
                            .map(|(_, d)| d.clone())
                            .unwrap_or_default();
                        (sub, d)
                    })
                    .collect();
            }

            // Suppress when the only candidate is an exact match of what was
            // already typed (nothing new to offer).
            let exact_only =
                candidates.len() == 1 && !trimmed.is_empty() && candidates[0].0 == trimmed;

            if candidates.is_empty() || exact_only {
                self.completion.active = false;
                return;
            }

            self.completion.candidates = candidates;
            self.completion.selected = 0;
            self.completion.arg_start = arg_start;
            self.completion.active = true;
            return;
        }

        // ─── Command token context ────────────────────────────────────────────
        self.completion.arg_start = 0;
        let prefix = text.trim(); // e.g. "/hel"

        let desc_for = |name: &str| -> String {
            self.command_descriptions
                .get(name)
                .cloned()
                .unwrap_or_default()
        };

        // Prefix match (fast path).
        let mut candidates: Vec<(String, String)> = self
            .all_command_names
            .iter()
            .filter(|cmd| cmd.starts_with(prefix) && *cmd != prefix)
            .map(|cmd| (cmd.clone(), desc_for(cmd)))
            .collect();

        // Fuzzy fallback for typos (jaro-winkler > 0.70).
        if candidates.is_empty() && prefix.len() >= 2 {
            let mut scored: Vec<(String, f64)> = self
                .all_command_names
                .iter()
                .map(|cmd| (cmd.clone(), strsim::jaro_winkler(prefix, cmd)))
                .filter(|(_, score)| *score > 0.7)
                .collect();
            scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
            candidates = scored
                .into_iter()
                .take(8)
                .map(|(cmd, _)| {
                    let d = desc_for(&cmd);
                    (cmd, d)
                })
                .collect();
        }

        // Skill candidates \u2014 visually distinct with \ud83d\udcda prefix in description.
        let query_after_slash = prefix.trim_start_matches('/').to_lowercase();
        let skill_candidates: Vec<(String, String)> = self
            .skills_completion_names
            .iter()
            .filter(|sn| {
                let sn_l = sn.to_lowercase();
                if query_after_slash.is_empty() {
                    return false;
                }
                sn_l.starts_with(&query_after_slash)
                    || (query_after_slash.len() >= 2
                        && strsim::jaro_winkler(&query_after_slash, &sn_l) > 0.72)
            })
            .map(|sn| {
                let desc = self
                    .skill_selector
                    .items
                    .iter()
                    .find(|e| &e.name == sn)
                    .map(|e| {
                        let type_tag = if e.is_dir { "dir" } else { "md" };
                        if e.desc.is_empty() {
                            format!("📚 skill [{type_tag}]")
                        } else {
                            format!("📚 {}", unicode_trunc(&e.desc, 50))
                        }
                    })
                    .unwrap_or_else(|| "📚 skill".to_string());
                (format!("/{sn}"), desc)
            })
            .take(6)
            .collect();

        let existing_names: std::collections::HashSet<String> =
            candidates.iter().map(|(c, _)| c.clone()).collect();
        for sc in skill_candidates {
            if !existing_names.contains(&sc.0) {
                candidates.push(sc);
            }
        }

        if candidates.is_empty() {
            self.completion.active = false;
        } else {
            self.completion.candidates = candidates;
            self.completion.selected = 0;
            self.completion.active = true;
        }
    }

    /// Accept the currently selected completion candidate.
    ///
    /// **Invariant: no typed content is ever discarded.**
    ///
    /// *Command context* (`arg_start == 0`):
    ///   The command token is replaced; any argument tail that follows the
    ///   first whitespace is re-inserted verbatim.
    ///   `/mo op` + accept `/model` → `/model op`
    ///
    /// *Argument context* (`arg_start > 0`):
    ///   The text up to `arg_start` (the command + space prefix) is kept;
    ///   only the fragment at `arg_start..` is replaced.
    ///   `/session sw` + accept `switch` → `/session switch `
    fn accept_completion(&mut self) {
        if !self.completion.active || self.completion.candidates.is_empty() {
            return;
        }
        let (selected, _desc) = self.completion.candidates[self.completion.selected].clone();
        let current = self.textarea_text();

        if self.completion.arg_start > 0 {
            // Argument completion: keep the prefix (command + spaces), replace
            // the fragment.  `arg_start` is a validated byte offset into ASCII
            // command names so direct byte slicing is safe.
            let prefix_text = current[..self.completion.arg_start].to_string();
            self.textarea_set_text(&format!("{}{} ", prefix_text, selected));
        } else {
            // Command completion: replace the command token, preserve arg tail.
            let arg_tail: String = if let Some(sp) = current.find(char::is_whitespace) {
                current[sp..].to_string()
            } else {
                " ".to_string()
            };
            self.textarea_set_text(&format!("{}{}", selected, arg_tail));
        }
        self.completion.active = false;
    }

    /// Returns just the next whitespace-delimited word from the ghost hint.
    ///
    /// Used by Alt+Right to accept the suggestion one word at a time instead of
    /// all at once.  Leading whitespace in the hint is included so that word
    /// boundaries are preserved naturally.
    fn ghost_hint_next_word(&self) -> Option<String> {
        let hint = self.ghost_hint()?;
        if hint.is_empty() {
            return None;
        }
        // Collect chars so we can index safely over multi-byte content.
        let chars: Vec<char> = hint.chars().collect();
        let mut end = 0;
        // Consume any leading whitespace (e.g. the space before the next word).
        while end < chars.len() && chars[end].is_whitespace() {
            end += 1;
        }
        // Consume the non-whitespace word itself.
        while end < chars.len() && !chars[end].is_whitespace() {
            end += 1;
        }
        // Greedily include one trailing space so the cursor lands between words.
        if end < chars.len() && chars[end] == ' ' {
            end += 1;
        }
        if end == 0 {
            return None;
        }
        Some(chars[..end].iter().collect())
    }

    fn open_compose_editor(&mut self, insert_newline: bool) {
        self.enter_compose_insert();
        if insert_newline {
            self.textarea.insert_newline();
        }
        self.completion.active = false;
    }

    fn apply_vim_operator_to_motion(&mut self, pending: VimPending, motion: CursorMove) {
        self.textarea.start_selection();
        self.textarea.move_cursor(motion);
        match pending {
            VimPending::Delete => {
                self.textarea.cut();
            }
            VimPending::Change => {
                self.textarea.cut();
                self.enter_compose_insert();
            }
            VimPending::Yank => {
                self.textarea.copy();
            }
            VimPending::Go => {}
        }
    }

    fn apply_vim_operator_to_word_end(&mut self, pending: VimPending) {
        self.textarea.start_selection();
        self.textarea.move_cursor(CursorMove::WordEnd);
        self.textarea.move_cursor(CursorMove::Forward);
        match pending {
            VimPending::Delete => {
                self.textarea.cut();
            }
            VimPending::Change => {
                self.textarea.cut();
                self.enter_compose_insert();
            }
            VimPending::Yank => {
                self.textarea.copy();
            }
            VimPending::Go => {}
        }
    }

    fn apply_vim_line_operator(&mut self, pending: VimPending) {
        self.textarea.cancel_selection();
        self.textarea.move_cursor(CursorMove::Head);
        self.textarea.start_selection();
        let cursor = self.textarea.cursor();
        self.textarea.move_cursor(CursorMove::Down);
        if cursor == self.textarea.cursor() {
            self.textarea.move_cursor(CursorMove::End);
        }
        match pending {
            VimPending::Delete => {
                self.textarea.cut();
            }
            VimPending::Change => {
                self.textarea.cut();
                self.enter_compose_insert();
            }
            VimPending::Yank => {
                self.textarea.copy();
            }
            VimPending::Go => {}
        }
    }

    fn handle_inline_input_key(&mut self, key: event::KeyEvent) {
        match (key.modifiers, key.code) {
            (mods, KeyCode::Enter)
                if mods.contains(KeyModifiers::SHIFT) || mods.contains(KeyModifiers::ALT) =>
            {
                self.open_compose_editor(true);
            }
            (KeyModifiers::CONTROL, KeyCode::Char('j')) => {
                self.open_compose_editor(true);
            }
            (KeyModifiers::CONTROL, KeyCode::Char('o')) => {
                self.open_compose_editor(true);
            }
            (KeyModifiers::NONE, KeyCode::Tab) => {
                if self.textarea.lines().len() > 1 {
                    self.textarea.input(key);
                    return;
                }
                self.update_completion();
                if self.completion.active {
                    return;
                }
                let (row, col) = self.textarea.cursor();
                let at_eol = col >= self.textarea.lines().get(row).map(|s| s.len()).unwrap_or(0);
                if at_eol && let Some(hint) = self.ghost_hint() {
                    for ch in hint.chars() {
                        self.textarea.insert_char(ch);
                    }
                }
            }
            (mods, KeyCode::Enter) if !mods.contains(KeyModifiers::SHIFT) => {
                self.submit_current_input();
            }
            (KeyModifiers::NONE, KeyCode::Up) if self.textarea.lines().len() <= 1 => {
                self.history_up();
            }
            (KeyModifiers::NONE, KeyCode::Down) if self.textarea.lines().len() <= 1 => {
                self.history_down();
            }
            (KeyModifiers::NONE, KeyCode::Right) => {
                let (row, col) = self.textarea.cursor();
                let line_len = self.textarea.lines().get(row).map(|s| s.len()).unwrap_or(0);
                if col >= line_len
                    && let Some(hint) = self.ghost_hint()
                {
                    for ch in hint.chars() {
                        self.textarea.insert_char(ch);
                    }
                    return;
                }
                self.textarea.input(key);
            }
            (KeyModifiers::ALT, KeyCode::Right) => {
                let (row, col) = self.textarea.cursor();
                let line_len = self.textarea.lines().get(row).map(|s| s.len()).unwrap_or(0);
                if col >= line_len
                    && let Some(word) = self.ghost_hint_next_word()
                {
                    for ch in word.chars() {
                        self.textarea.insert_char(ch);
                    }
                    return;
                }
                self.textarea.input(key);
            }
            (KeyModifiers::NONE, KeyCode::End) => {
                let (row, col) = self.textarea.cursor();
                let line_len = self.textarea.lines().get(row).map(|s| s.len()).unwrap_or(0);
                if col >= line_len
                    && let Some(hint) = self.ghost_hint()
                {
                    for ch in hint.chars() {
                        self.textarea.insert_char(ch);
                    }
                    return;
                }
                self.textarea.input(key);
            }
            _ => {
                self.textarea.input(key);
            }
        }
    }

    fn handle_compose_insert_key(&mut self, key: event::KeyEvent) {
        match (key.modifiers, key.code) {
            (KeyModifiers::CONTROL, KeyCode::Char('s'))
            | (KeyModifiers::CONTROL, KeyCode::Enter) => self.submit_current_input(),
            (KeyModifiers::CONTROL, KeyCode::Char('j')) => self.textarea.insert_newline(),
            (KeyModifiers::CONTROL, KeyCode::Char('[')) | (_, KeyCode::Esc) => {
                self.enter_compose_normal();
            }
            _ => {
                self.textarea.input(key);
            }
        }
    }

    fn handle_compose_normal_key(&mut self, key: event::KeyEvent) {
        if matches!(
            (key.modifiers, key.code),
            (KeyModifiers::CONTROL, KeyCode::Char('s')) | (KeyModifiers::CONTROL, KeyCode::Enter)
        ) {
            self.submit_current_input();
            return;
        }

        if matches!(key.code, KeyCode::Esc)
            || matches!(
                (key.modifiers, key.code),
                (KeyModifiers::CONTROL, KeyCode::Char('['))
            )
        {
            if self.vim_pending.take().is_none() {
                self.exit_compose_mode();
            }
            return;
        }

        if let Some(pending) = self.vim_pending.take() {
            match (pending, key.code) {
                (VimPending::Go, KeyCode::Char('g')) => self.textarea.move_cursor(CursorMove::Top),
                (VimPending::Delete, KeyCode::Char('d')) => self.apply_vim_line_operator(pending),
                (VimPending::Change, KeyCode::Char('c')) => self.apply_vim_line_operator(pending),
                (VimPending::Yank, KeyCode::Char('y')) => self.apply_vim_line_operator(pending),
                (
                    VimPending::Delete | VimPending::Change | VimPending::Yank,
                    KeyCode::Char('w'),
                ) => {
                    self.apply_vim_operator_to_motion(pending, CursorMove::WordForward);
                }
                (
                    VimPending::Delete | VimPending::Change | VimPending::Yank,
                    KeyCode::Char('b'),
                ) => {
                    self.apply_vim_operator_to_motion(pending, CursorMove::WordBack);
                }
                (
                    VimPending::Delete | VimPending::Change | VimPending::Yank,
                    KeyCode::Char('e'),
                ) => {
                    self.apply_vim_operator_to_word_end(pending);
                }
                (VimPending::Delete | VimPending::Change, KeyCode::Char('$')) => {
                    self.textarea.delete_line_by_end();
                    if matches!(pending, VimPending::Change) {
                        self.enter_compose_insert();
                    }
                }
                (VimPending::Yank, KeyCode::Char('$')) => {
                    self.textarea.start_selection();
                    self.textarea.move_cursor(CursorMove::End);
                    self.textarea.copy();
                }
                _ => {}
            }
            return;
        }

        match (key.modifiers, key.code) {
            (_, KeyCode::Enter) => {}
            (KeyModifiers::NONE, KeyCode::Left | KeyCode::Char('h')) => {
                self.textarea.move_cursor(CursorMove::Back)
            }
            (KeyModifiers::NONE, KeyCode::Down | KeyCode::Char('j')) => {
                self.textarea.move_cursor(CursorMove::Down)
            }
            (KeyModifiers::NONE, KeyCode::Up | KeyCode::Char('k')) => {
                self.textarea.move_cursor(CursorMove::Up)
            }
            (KeyModifiers::NONE, KeyCode::Right | KeyCode::Char('l')) => {
                self.textarea.move_cursor(CursorMove::Forward)
            }
            (KeyModifiers::NONE, KeyCode::Char('w')) => {
                self.textarea.move_cursor(CursorMove::WordForward)
            }
            (KeyModifiers::NONE, KeyCode::Char('b')) => {
                self.textarea.move_cursor(CursorMove::WordBack)
            }
            (KeyModifiers::NONE, KeyCode::Char('e')) => {
                self.textarea.move_cursor(CursorMove::WordEnd)
            }
            (KeyModifiers::NONE, KeyCode::Home | KeyCode::Char('0'))
            | (KeyModifiers::NONE, KeyCode::Char('^')) => {
                self.textarea.move_cursor(CursorMove::Head)
            }
            (KeyModifiers::NONE, KeyCode::End | KeyCode::Char('$')) => {
                self.textarea.move_cursor(CursorMove::End)
            }
            (KeyModifiers::NONE, KeyCode::Char('g')) => self.vim_pending = Some(VimPending::Go),
            (KeyModifiers::SHIFT, KeyCode::Char('g'))
            | (KeyModifiers::NONE, KeyCode::Char('G')) => {
                self.textarea.move_cursor(CursorMove::Bottom)
            }
            (KeyModifiers::NONE, KeyCode::Char('i')) => self.enter_compose_insert(),
            (KeyModifiers::NONE, KeyCode::Char('a')) => {
                self.textarea.move_cursor(CursorMove::Forward);
                self.enter_compose_insert();
            }
            (KeyModifiers::SHIFT, KeyCode::Char('i'))
            | (KeyModifiers::NONE, KeyCode::Char('I')) => {
                self.textarea.move_cursor(CursorMove::Head);
                self.enter_compose_insert();
            }
            (KeyModifiers::SHIFT, KeyCode::Char('a'))
            | (KeyModifiers::NONE, KeyCode::Char('A')) => {
                self.textarea.move_cursor(CursorMove::End);
                self.enter_compose_insert();
            }
            (KeyModifiers::NONE, KeyCode::Char('o')) => {
                self.textarea.move_cursor(CursorMove::End);
                self.textarea.insert_newline();
                self.enter_compose_insert();
            }
            (KeyModifiers::SHIFT, KeyCode::Char('o'))
            | (KeyModifiers::NONE, KeyCode::Char('O')) => {
                self.textarea.move_cursor(CursorMove::Head);
                self.textarea.insert_newline();
                self.textarea.move_cursor(CursorMove::Up);
                self.enter_compose_insert();
            }
            (KeyModifiers::NONE, KeyCode::Char('x')) => {
                self.textarea.delete_next_char();
            }
            (KeyModifiers::NONE, KeyCode::Char('s')) => {
                self.textarea.delete_next_char();
                self.enter_compose_insert();
            }
            (KeyModifiers::NONE, KeyCode::Char('p')) => {
                self.textarea.paste();
            }
            (KeyModifiers::NONE, KeyCode::Char('u')) => {
                self.textarea.undo();
            }
            (KeyModifiers::CONTROL, KeyCode::Char('r')) => {
                self.textarea.redo();
            }
            (KeyModifiers::NONE, KeyCode::Char('d')) => self.vim_pending = Some(VimPending::Delete),
            (KeyModifiers::NONE, KeyCode::Char('c')) => self.vim_pending = Some(VimPending::Change),
            (KeyModifiers::NONE, KeyCode::Char('y')) => self.vim_pending = Some(VimPending::Yank),
            (KeyModifiers::SHIFT, KeyCode::Char('c'))
            | (KeyModifiers::NONE, KeyCode::Char('C')) => {
                self.textarea.delete_line_by_end();
                self.enter_compose_insert();
            }
            (KeyModifiers::SHIFT, KeyCode::Char('d'))
            | (KeyModifiers::NONE, KeyCode::Char('D')) => {
                self.textarea.delete_line_by_end();
            }
            (KeyModifiers::SHIFT, KeyCode::Char('s'))
            | (KeyModifiers::NONE, KeyCode::Char('S')) => {
                self.apply_vim_line_operator(VimPending::Change);
            }
            (KeyModifiers::SHIFT, KeyCode::Char('y'))
            | (KeyModifiers::NONE, KeyCode::Char('Y')) => {
                self.textarea.start_selection();
                self.textarea.move_cursor(CursorMove::End);
                self.textarea.copy();
            }
            _ => {}
        }
    }

    fn set_skill_activation(&mut self, name: &str, active: bool) {
        let skill_md = Self::find_skill_md(name);
        let skill_md = match skill_md {
            Some(p) => p,
            None => {
                self.push_output(
                    format!("Skill '{name}' not found. Type /skills to browse available skills."),
                    OutputRole::Error,
                );
                return;
            }
        };
        let current = self.active_skills.iter().any(|skill| skill == name);
        if current == active {
            let state = if active {
                "already active"
            } else {
                "already inactive"
            };
            self.push_output(format!("📚 Skill '{name}' is {state}."), OutputRole::System);
            return;
        }

        if active {
            self.active_skills.push(name.to_string());
            let desc = Self::read_skill_desc(&skill_md);
            let msg = if desc.is_empty() {
                format!("📚 Skill '{name}' activated — it is now preloaded for this session.")
            } else {
                format!("📚 Skill '{name}' activated: {desc}")
            };
            self.push_output(msg, OutputRole::System);
        } else {
            self.active_skills.retain(|skill| skill != name);
            self.push_output(
                format!("📚 Skill '{name}' deactivated."),
                OutputRole::System,
            );
        }

        if let Some(agent) = self.agent.clone() {
            let active = self.active_skills.clone();
            self.rt_handle
                .block_on(async move { agent.set_preloaded_skills(active).await });
        }

        self.refresh_skills_list();
        self.needs_redraw = true;
    }

    /// Activate or deactivate a skill by name.
    ///
    /// Typing `/skill_name` a second time toggles the skill off. Active skills
    /// are stored as session-scoped preloaded skills on the agent config,
    /// mirroring Hermes-style preloaded skill semantics.
    fn activate_skill(&mut self, name: &str) {
        let next_state = !self.active_skills.iter().any(|skill| skill == name);
        self.set_skill_activation(name, next_state);
    }

    // ─── Tab Completion ─────────────────────────────────────────────

    // Update completion candidates based on current input.
    // ─── History ────────────────────────────────────────────────────

    /// Path to the persistent history file.
    fn history_path() -> Option<std::path::PathBuf> {
        Some(edgecrab_core::edgecrab_home().join("history"))
    }

    /// Load history from disk (called once at startup).
    fn load_history_file(&mut self) {
        if let Some(path) = Self::history_path()
            && let Ok(content) = std::fs::read_to_string(&path)
        {
            self.input_history = content
                .lines()
                .filter(|l| !l.is_empty())
                .map(String::from)
                .collect();
            // Cap at 500
            if self.input_history.len() > 500 {
                let excess = self.input_history.len() - 500;
                self.input_history.drain(..excess);
            }
            self.history_pos = self.input_history.len();
        }
    }

    /// Persist history to disk (called after each push and on exit).
    fn save_history_file(&self) {
        if let Some(path) = Self::history_path() {
            if let Some(parent) = path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let content = self.input_history.join("\n");
            let _ = std::fs::write(&path, content);
        }
    }

    fn history_up(&mut self) {
        self.ensure_history_loaded();
        if self.input_history.is_empty() {
            return;
        }
        if self.history_pos == self.input_history.len() {
            // Stash current input before navigating
            self.history_stash = self.textarea_text();
        }
        if self.history_pos > 0 {
            self.history_pos -= 1;
            let entry = self.input_history[self.history_pos].clone();
            self.textarea_set_text(&entry);
        }
    }

    fn history_down(&mut self) {
        self.ensure_history_loaded();
        if self.history_pos >= self.input_history.len() {
            return;
        }
        self.history_pos += 1;
        let text = if self.history_pos == self.input_history.len() {
            self.history_stash.clone()
        } else {
            self.input_history[self.history_pos].clone()
        };
        self.textarea_set_text(&text);
    }

    fn push_history(&mut self, entry: &str) {
        self.ensure_history_loaded();
        let trimmed = entry.trim().to_string();
        if trimmed.is_empty() {
            return;
        }
        // Dedup consecutive
        if self
            .input_history
            .last()
            .is_some_and(|last| *last == trimmed)
        {
            return;
        }
        self.input_history.push(trimmed);
        // Cap at 500
        if self.input_history.len() > 500 {
            self.input_history.remove(0);
        }
        self.history_pos = self.input_history.len();
        // Persist immediately
        self.save_history_file();
    }

    // ─── Ghost text (Fish-style history hint) ───────────────────────

    fn ghost_hint(&self) -> Option<String> {
        let text = self.textarea_text();
        if text.len() < 2 || text.contains('\n') {
            return None;
        }
        // Search history from most recent backwards
        for entry in self.input_history.iter().rev() {
            if entry.starts_with(&text) && entry != &text {
                return Some(entry[text.len()..].to_string());
            }
        }
        None
    }

    /// Scroll the output area by `delta` visual rows (positive = up, negative = down).
    fn scroll_output(&mut self, delta: i32) {
        let max_scroll = self
            .output_visual_rows
            .saturating_sub(self.output_area_height);
        let new_offset = (self.scroll_offset as i32 + delta).clamp(0, max_scroll as i32) as u16;
        self.scroll_offset = new_offset;
        self.at_bottom = self.scroll_offset == 0;
        self.needs_redraw = true;
    }

    fn apply_output_paging(&mut self, intent: PagingIntent) {
        if self.editor_mode.is_compose() {
            match intent {
                PagingIntent::Up => self.textarea.scroll(Scrolling::PageUp),
                PagingIntent::Down => self.textarea.scroll(Scrolling::PageDown),
            }
            self.needs_redraw = true;
            return;
        }

        let page = self.output_area_height.max(3).saturating_sub(2) as i32;
        match intent {
            PagingIntent::Up => self.scroll_output(page),
            PagingIntent::Down => self.scroll_output(-page),
        }
    }

    fn handle_paging_intent(&mut self, intent: PagingIntent) -> bool {
        // Log browser and log inspector manage their own paging. Let their
        // key handlers run instead of consuming the key here.
        if self.log_inspector.active() || self.log_browser.active {
            return false;
        }

        if self.document_overlay.is_some() {
            self.scroll_document_overlay(match intent {
                PagingIntent::Up => -8,
                PagingIntent::Down => 8,
            });
            return true;
        }

        if self.model_selector.active {
            self.apply_simple_selector_paging(DetailSurface::ModelSelector, intent);
            return true;
        }

        if self.gateway_browser.active {
            match intent {
                PagingIntent::Up
                    if self.detail_fullscreen_active(DetailSurface::GatewayBrowser) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::GatewayBrowser);
                }
                PagingIntent::Down
                    if self.detail_fullscreen_active(DetailSurface::GatewayBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::GatewayBrowser);
                }
                PagingIntent::Up => {
                    if self.gateway_browser_pane.focus == SplitPaneFocus::Detail {
                        self.gateway_browser_pane.page_up(8);
                    } else {
                        self.gateway_browser.page_up();
                        self.gateway_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                    }
                }
                PagingIntent::Down => {
                    if self.gateway_browser_pane.focus == SplitPaneFocus::Detail {
                        self.gateway_browser_pane.page_down(8);
                    } else {
                        self.gateway_browser.page_down();
                        self.gateway_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                    }
                }
            }
            self.needs_redraw = true;
            return true;
        }

        if self.moa_reference_selector.active {
            match intent {
                PagingIntent::Up => self.moa_reference_selector.page_up(),
                PagingIntent::Down => self.moa_reference_selector.page_down(),
            }
            return true;
        }

        macro_rules! handle_split_detail_paging {
            ($active:expr, $surface:expr) => {
                if $active {
                    self.apply_simple_selector_paging($surface, intent);
                    return true;
                }
            };
        }

        handle_split_detail_paging!(
            self.vision_model_selector.active,
            DetailSurface::VisionModelSelector
        );
        handle_split_detail_paging!(
            self.image_model_selector.active,
            DetailSurface::ImageModelSelector
        );
        handle_split_detail_paging!(self.mcp_selector.active, DetailSurface::McpSelector);
        handle_split_detail_paging!(
            self.remote_mcp_browser.selector.active,
            DetailSurface::RemoteMcpBrowser
        );
        handle_split_detail_paging!(
            self.remote_skill_browser.selector.active,
            DetailSurface::RemoteSkillBrowser
        );
        handle_split_detail_paging!(
            self.remote_plugin_browser.selector.active,
            DetailSurface::RemotePluginBrowser
        );
        handle_split_detail_paging!(self.profile_selector.active, DetailSurface::ProfileSelector);
        handle_split_detail_paging!(self.skill_selector.active, DetailSurface::SkillSelector);
        handle_split_detail_paging!(self.tool_manager.active, DetailSurface::ToolManager);
        handle_split_detail_paging!(self.plugin_toggle.active, DetailSurface::PluginToggle);
        handle_split_detail_paging!(self.config_selector.active, DetailSurface::ConfigSelector);

        if self.skin_browser.active {
            match intent {
                PagingIntent::Up => self.skin_browser.page_up(),
                PagingIntent::Down => self.skin_browser.page_down(),
            }
            return true;
        }

        if self.session_inspector.active() {
            match intent {
                PagingIntent::Up
                    if self.detail_fullscreen_active(DetailSurface::SessionInspector) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::SessionInspector);
                }
                PagingIntent::Down
                    if self.detail_fullscreen_active(DetailSurface::SessionInspector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::SessionInspector);
                }
                PagingIntent::Up => {
                    if self.session_inspector.pane.focus == SplitPaneFocus::Detail {
                        self.session_inspector.pane.page_up(8);
                    } else {
                        self.session_inspector.selector.page_up();
                        self.session_inspector.pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                    }
                }
                PagingIntent::Down => {
                    if self.session_inspector.pane.focus == SplitPaneFocus::Detail {
                        self.session_inspector.pane.page_down(8);
                    } else {
                        self.session_inspector.selector.page_down();
                        self.session_inspector.pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                    }
                }
            }
            self.needs_redraw = true;
            return true;
        }

        if self.session_browser.active {
            match intent {
                PagingIntent::Up
                    if self.detail_fullscreen_active(DetailSurface::SessionBrowser) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::SessionBrowser);
                }
                PagingIntent::Down
                    if self.detail_fullscreen_active(DetailSurface::SessionBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::SessionBrowser);
                }
                PagingIntent::Up => {
                    if self.session_browser_pane.focus == SplitPaneFocus::Detail {
                        self.session_browser_pane.page_up(8);
                    } else {
                        self.session_browser.page_up();
                        self.session_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    }
                }
                PagingIntent::Down => {
                    if self.session_browser_pane.focus == SplitPaneFocus::Detail {
                        self.session_browser_pane.page_down(8);
                    } else {
                        self.session_browser.page_down();
                        self.session_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    }
                }
            }
            self.needs_redraw = true;
            return true;
        }

        if self.completion.active {
            if !self.completion.candidates.is_empty() {
                match intent {
                    PagingIntent::Up => {
                        self.completion.selected = self.completion.selected.saturating_sub(8);
                    }
                    PagingIntent::Down => {
                        let last = self.completion.candidates.len() - 1;
                        self.completion.selected = (self.completion.selected + 8).min(last);
                    }
                }
            }
            return true;
        }

        self.apply_output_paging(intent);
        true
    }

    fn simple_split_focus_supported(surface: DetailSurface) -> bool {
        matches!(
            surface,
            DetailSurface::ModelSelector
                | DetailSurface::VisionModelSelector
                | DetailSurface::ImageModelSelector
                | DetailSurface::McpSelector
                | DetailSurface::RemoteMcpBrowser
                | DetailSurface::SkillSelector
                | DetailSurface::RemoteSkillBrowser
                | DetailSurface::RemotePluginBrowser
                | DetailSurface::ToolManager
                | DetailSurface::PluginToggle
                | DetailSurface::ConfigSelector
        )
    }

    fn simple_split_focus(&self, surface: DetailSurface) -> SplitPaneFocus {
        self.simple_detail_state.focus_for(surface)
    }

    fn simple_split_detail_focused(&self, surface: DetailSurface) -> bool {
        Self::simple_split_focus_supported(surface)
            && self.simple_split_focus(surface) == SplitPaneFocus::Detail
            && !self.detail_fullscreen_active(surface)
    }

    fn simple_split_focus_label(&self, surface: DetailSurface) -> &'static str {
        match self.simple_split_focus(surface) {
            SplitPaneFocus::List => "list",
            SplitPaneFocus::Detail => "detail",
        }
    }

    fn toggle_simple_split_focus(&mut self, surface: DetailSurface) {
        if !Self::simple_split_focus_supported(surface) {
            return;
        }
        let next = self.simple_split_focus(surface).toggle();
        self.simple_detail_state.set_focus(surface, next);
        self.needs_redraw = true;
    }

    fn scroll_split_detail_lines(&mut self, surface: DetailSurface, delta: i16) {
        let scroll = self.split_detail_scroll(surface);
        let next = if delta.is_negative() {
            scroll.saturating_sub(delta.unsigned_abs())
        } else {
            scroll.saturating_add(delta as u16)
        };
        self.set_split_detail_scroll(surface, next);
        self.needs_redraw = true;
    }

    fn set_split_detail_scroll_to_edge(&mut self, surface: DetailSurface, end: bool) {
        self.set_split_detail_scroll(surface, if end { u16::MAX } else { 0 });
        self.needs_redraw = true;
    }

    /// Handle a pasted text string (from bracketed paste events or /paste command).
    ///
    /// If the pasted text is (or contains) a path to a local image file, the path is
    /// added to `pending_images` instead of the textarea so it is automatically
    /// analysed by the vision LLM when the user sends their next message.
    pub fn handle_paste(&mut self, text: String) {
        let trimmed = text.trim();
        if Self::is_image_path(trimmed) && std::path::Path::new(trimmed).is_file() {
            let path = std::path::PathBuf::from(trimmed);
            self.pending_images.push(path);
            let count = self.pending_images.len();
            self.push_output(
                format!(
                    "📎 Image file attached: {}  ({} image(s) queued — send a message to analyse with vision LLM)",
                    trimmed, count
                ),
                OutputRole::System,
            );
        } else {
            // Normal text paste — insert into textarea
            self.textarea.insert_str(&text);
        }
        self.needs_redraw = true;
    }

    /// Return true if `s` looks like a local image file path (by extension).
    fn is_image_path(s: &str) -> bool {
        let lower = s.to_ascii_lowercase();
        matches!(
            std::path::Path::new(&lower)
                .extension()
                .and_then(|e| e.to_str()),
            Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "tif" | "avif")
        )
    }

    /// Handle a crossterm mouse event.
    /// Scroll wheel maps to page-based output scroll so users can navigate
    /// without lifting hands from the mouse.
    pub fn handle_mouse_event(&mut self, event: event::MouseEvent) {
        if !self.mouse_capture_enabled {
            return;
        }

        match event.kind {
            // Scroll wheel up → scroll content upward (away from bottom)
            MouseEventKind::ScrollUp => {
                self.scroll_output(5);
            }
            // Scroll wheel down → scroll content downward (toward bottom)
            MouseEventKind::ScrollDown => {
                self.scroll_output(-5);
            }
            // Touchpad horizontal gestures: map to larger vertical scroll jumps.
            MouseEventKind::ScrollLeft => {
                self.scroll_output(12);
            }
            MouseEventKind::ScrollRight => {
                self.scroll_output(-12);
            }
            // Left click: collapse overlays.
            // Double-click (two clicks ≤400 ms apart) in SCROLL mode → switch to SELECT.
            // The reverse (SELECT→SCROLL) is not possible via click because mouse events
            // are not delivered to the process when capture is off; use F6, Ctrl+M, or
            // `/scroll on` instead.
            MouseEventKind::Down(MouseButton::Left) => {
                self.completion.active = false;
                self.model_selector.active = false;
                self.vision_model_selector.active = false;
                self.image_model_selector.active = false;
                self.moa_reference_selector.active = false;
                self.mcp_selector.active = false;
                self.remote_mcp_browser.selector.active = false;
                self.skill_selector.active = false;
                self.remote_skill_browser.selector.active = false;
                self.verbose_selector_active = false;
                self.reasoning_selector_active = false;
                self.personality_selector_active = false;
                self.stream_selector_active = false;
                self.statusbar_selector_active = false;
                self.needs_redraw = true;
                let now = Instant::now();
                let is_double = self
                    .last_left_click
                    .map(|t| now.duration_since(t).as_millis() <= 400)
                    .unwrap_or(false);
                if is_double {
                    self.last_left_click = None;
                    self.toggle_mouse_capture_mode();
                } else {
                    self.last_left_click = Some(now);
                }
            }
            _ => {}
        }
    }

    /// Handle a crossterm key event.
    pub fn handle_key_event(&mut self, key: event::KeyEvent) {
        // Only process key press events, ignore release events (prevents double-fire on Windows)
        if key.kind == KeyEventKind::Release {
            return;
        }
        let raw_key = key;
        let key = normalize_fallback_paging_key(normalize_terminal_control_key(key));

        self.needs_redraw = true;

        if key_matches_binding(&key, &self.voice_push_to_talk_key) {
            self.toggle_voice_recording(true);
            return;
        }

        // Global shortcuts first — these work regardless of any overlay
        match (key.modifiers, key.code) {
            // Ctrl+C — clear input → cancel agent → exit  (standard readline behaviour)
            (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
                let text = self.textarea_text();
                if !text.is_empty() {
                    // Non-empty input: clear it (like ^C at a shell prompt)
                    self.textarea_clear();
                    self.completion.active = false;
                    self.history_pos = self.input_history.len();
                    self.push_output("^C", OutputRole::System);
                } else if self.voice_recording.is_some() {
                    self.abort_voice_recording("^C  (voice recording cancelled)");
                } else if self.is_processing {
                    self.cancel_active_request();
                    self.push_output("^C  (cancelled)", OutputRole::System);
                } else {
                    // Nothing to do: exit
                    self.should_exit = true;
                }
                return;
            }
            // Ctrl+D — exit (EOF signal, identical to shell behaviour)
            (KeyModifiers::CONTROL, KeyCode::Char('d')) => {
                let text = self.textarea_text();
                if text.is_empty() {
                    self.should_exit = true;
                }
                // Non-empty: let textarea handle delete-char (standard readline)
                return;
            }
            // Ctrl+L — clear screen (standard shell shortcut)
            (KeyModifiers::CONTROL, KeyCode::Char('l')) => {
                self.clear_output();
                return;
            }
            // Ctrl+Shift+V — paste clipboard image (or text) into conversation.
            // Ctrl+V (without Shift) arrives as a bracketed-paste Event::Paste, so
            // this shortcut gives explicit access to the arboard clipboard reader
            // which can capture raw images (screenshots, browser copies, etc.).
            (m, KeyCode::Char('v'))
                if m.contains(KeyModifiers::CONTROL) && m.contains(KeyModifiers::SHIFT) =>
            {
                self.handle_paste_clipboard();
                return;
            }
            // F6 — toggle mouse capture mode for copy/select ergonomics.
            (_, KeyCode::F(6)) => {
                self.toggle_mouse_capture_mode();
                return;
            }
            // Ctrl+M — alternate toggle for mouse capture mode.
            (KeyModifiers::CONTROL, KeyCode::Char('m')) => {
                self.toggle_mouse_capture_mode();
                return;
            }
            // Ctrl+U — clear current input line (standard readline shortcut)
            (KeyModifiers::CONTROL, KeyCode::Char('u')) => {
                self.textarea_clear();
                self.completion.active = false;
                return;
            }
            // Ctrl+G — scroll output to very bottom (jump back to live view)
            (KeyModifiers::CONTROL, KeyCode::Char('g')) => {
                self.scroll_offset = 0;
                self.at_bottom = true;
                self.needs_redraw = true;
                return;
            }
            // Ctrl+K — kill text from cursor to end of line (readline standard)
            (KeyModifiers::CONTROL, KeyCode::Char('k')) => {
                self.textarea.delete_line_by_end();
                self.needs_redraw = true;
                return;
            }
            // Ctrl+A — move cursor to beginning of line (readline standard)
            (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
                self.textarea.move_cursor(CursorMove::Head);
                self.needs_redraw = true;
                return;
            }
            // Ctrl+E — move cursor to end of line (readline standard)
            (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
                self.textarea.move_cursor(CursorMove::End);
                self.needs_redraw = true;
                return;
            }
            // Ctrl+Home — scroll output to very top
            (KeyModifiers::CONTROL, KeyCode::Home) => {
                let max_scroll = self
                    .output_visual_rows
                    .saturating_sub(self.output_area_height);
                self.scroll_offset = max_scroll;
                self.at_bottom = false;
                return;
            }
            // Ctrl+End — scroll output to very bottom
            (KeyModifiers::CONTROL, KeyCode::End) => {
                self.scroll_offset = 0;
                self.at_bottom = true;
                return;
            }
            // Shift+Up — scroll output up one line (doesn't conflict with history navigation)
            (KeyModifiers::SHIFT, KeyCode::Up) => {
                self.scroll_output(5);
                return;
            }
            // Shift+Down — scroll output down one line
            (KeyModifiers::SHIFT, KeyCode::Down) => {
                self.scroll_output(-5);
                return;
            }
            // Alt+Up — scroll output up (works in multi-line input mode)
            (KeyModifiers::ALT, KeyCode::Up) => {
                self.scroll_output(5);
                return;
            }
            // Alt+Down — scroll output down
            (KeyModifiers::ALT, KeyCode::Down) => {
                self.scroll_output(-5);
                return;
            }
            // F1 — show help overlay
            (_, KeyCode::F(1)) => {
                self.process_input("/help");
                return;
            }
            // F2 — open model selector
            (_, KeyCode::F(2)) => {
                self.refresh_model_selector_catalog();
                return;
            }
            // F3 — open skill browser (same experience as F2 for models)
            (_, KeyCode::F(3)) => {
                self.refresh_skills_list();
                self.open_skill_selector();
                return;
            }
            // F7 — open dedicated vision-model selector
            (_, KeyCode::F(7)) => {
                self.open_vision_model_selector();
                return;
            }
            // F4 — open session browser overlay
            (_, KeyCode::F(4)) => {
                self.open_session_browser();
                return;
            }
            // F5 — retry last message
            (_, KeyCode::F(5)) => {
                self.process_input("/retry");
                return;
            }
            // F10 - cycle tool progress mode
            (_, KeyCode::F(10)) => {
                self.process_input("/verbose");
                return;
            }
            _ => {}
        }

        if let Some(intent) = paging_intent_for_key(raw_key).or_else(|| paging_intent_for_key(key))
            && self.handle_paging_intent(intent)
        {
            return;
        }

        // Approval overlay active — intercept all keys for choice navigation
        if matches!(self.display_state, DisplayState::WaitingForApproval { .. }) {
            self.handle_approval_key(key);
            return;
        }

        // Secret capture overlay active — intercept all keys for masked input
        if matches!(self.display_state, DisplayState::SecretCapture { .. }) {
            self.handle_secret_capture_key(key);
            return;
        }

        if matches!(self.display_state, DisplayState::ValueCapture { .. }) {
            self.handle_value_capture_key(key);
            return;
        }

        if self.document_overlay.is_some() {
            match key.code {
                KeyCode::Esc => self.close_document_overlay(),
                KeyCode::Up => self.scroll_document_overlay(-1),
                KeyCode::Down => self.scroll_document_overlay(1),
                KeyCode::PageUp => self.scroll_document_overlay(-8),
                KeyCode::PageDown => self.scroll_document_overlay(8),
                KeyCode::Home => self.set_document_overlay_scroll(0),
                KeyCode::End => self.set_document_overlay_scroll(u16::MAX),
                _ => {}
            }
            return;
        }

        // ── Steering overlay active — intercept all keys ──────────────────
        if self.steering_overlay_active {
            self.handle_steering_overlay_key(key);
            return;
        }

        // Ctrl+S — open steering overlay (when agent is running) or send steer
        // as a new message if idle (EC-04).
        if matches!(
            (key.modifiers, key.code),
            (KeyModifiers::CONTROL, KeyCode::Char('s'))
        ) && !matches!(
            self.editor_mode,
            InputEditorMode::ComposeInsert | InputEditorMode::ComposeNormal
        ) {
            self.open_steering_overlay();
            return;
        }

        // ── Steering overlay active — intercept all keys ──────────────────
        if self.steering_overlay_active {
            self.handle_steering_overlay_key(key);
            return;
        }

        // Ctrl+S — open steering overlay (when agent is running) or send steer
        // as a new message if idle (EC-04).
        if matches!(
            (key.modifiers, key.code),
            (KeyModifiers::CONTROL, KeyCode::Char('s'))
        ) && !matches!(
            self.editor_mode,
            InputEditorMode::ComposeInsert | InputEditorMode::ComposeNormal
        ) {
            self.open_steering_overlay();
            return;
        }

        // Gateway Diagnostics overlay — full-screen scrollable report
        if self.diagnose_panel.active {
            self.handle_diagnose_panel_key(key);
            return;
        }

        // Reasoning picker overlay active — intercept all keys
        if self.reasoning_selector_active {
            match key.code {
                KeyCode::Esc => {
                    self.reasoning_selector_active = false;
                    self.needs_redraw = true;
                }
                KeyCode::Up | KeyCode::BackTab => {
                    if self.reasoning_selector_cursor > 0 {
                        self.reasoning_selector_cursor -= 1;
                    } else {
                        self.reasoning_selector_cursor = 4;
                    }
                    self.needs_redraw = true;
                }
                KeyCode::Down | KeyCode::Tab => {
                    self.reasoning_selector_cursor = (self.reasoning_selector_cursor + 1) % 5;
                    self.needs_redraw = true;
                }
                KeyCode::Enter => {
                    let arg =
                        ["low", "medium", "high", "show", "hide"][self.reasoning_selector_cursor];
                    self.reasoning_selector_active = false;
                    self.handle_set_reasoning(arg.to_string());
                    self.needs_redraw = true;
                }
                _ => {}
            }
            return;
        }

        // Personality picker overlay active — intercept all keys
        if self.personality_selector_active {
            match key.code {
                KeyCode::Esc => {
                    self.personality_selector_active = false;
                    self.needs_redraw = true;
                }
                KeyCode::Up | KeyCode::BackTab => {
                    let n = self.personality_selector_entries.len();
                    if n > 0 {
                        if self.personality_selector_cursor > 0 {
                            self.personality_selector_cursor -= 1;
                        } else {
                            self.personality_selector_cursor = n.saturating_sub(1);
                        }
                    }
                    self.needs_redraw = true;
                }
                KeyCode::Down | KeyCode::Tab => {
                    let n = self.personality_selector_entries.len();
                    if n > 0 {
                        self.personality_selector_cursor =
                            (self.personality_selector_cursor + 1) % n;
                    }
                    self.needs_redraw = true;
                }
                KeyCode::Enter => {
                    if let Some((name, _)) = self
                        .personality_selector_entries
                        .get(self.personality_selector_cursor)
                        .cloned()
                    {
                        self.personality_selector_active = false;
                        self.handle_switch_personality(name);
                        self.needs_redraw = true;
                    }
                }
                _ => {}
            }
            return;
        }

        // Stream picker overlay active — intercept all keys
        if self.stream_selector_active {
            match key.code {
                KeyCode::Esc => {
                    self.stream_selector_active = false;
                    self.needs_redraw = true;
                }
                KeyCode::Up | KeyCode::BackTab | KeyCode::Down | KeyCode::Tab => {
                    self.stream_selector_cursor = 1 - self.stream_selector_cursor;
                    self.needs_redraw = true;
                }
                KeyCode::Enter => {
                    let arg = if self.stream_selector_cursor == 0 {
                        "on"
                    } else {
                        "off"
                    };
                    self.stream_selector_active = false;
                    self.handle_set_streaming(arg.to_string());
                    self.needs_redraw = true;
                }
                _ => {}
            }
            return;
        }

        // Status bar picker overlay active — intercept all keys
        if self.statusbar_selector_active {
            match key.code {
                KeyCode::Esc => {
                    self.statusbar_selector_active = false;
                    self.needs_redraw = true;
                }
                KeyCode::Up | KeyCode::BackTab | KeyCode::Down | KeyCode::Tab => {
                    self.statusbar_selector_cursor = 1 - self.statusbar_selector_cursor;
                    self.needs_redraw = true;
                }
                KeyCode::Enter => {
                    let arg = if self.statusbar_selector_cursor == 0 {
                        "on"
                    } else {
                        "off"
                    };
                    self.statusbar_selector_active = false;
                    self.handle_status_bar_command(arg.to_string());
                    self.needs_redraw = true;
                }
                _ => {}
            }
            return;
        }

        // Verbose / tool-progress picker overlay active — intercept all keys
        if self.verbose_selector_active {
            match key.code {
                KeyCode::Esc => {
                    self.verbose_selector_active = false;
                    self.needs_redraw = true;
                }
                KeyCode::Up | KeyCode::BackTab => {
                    if self.verbose_selector_cursor > 0 {
                        self.verbose_selector_cursor -= 1;
                    } else {
                        self.verbose_selector_cursor = 3;
                    }
                    self.needs_redraw = true;
                }
                KeyCode::Down | KeyCode::Tab => {
                    self.verbose_selector_cursor = (self.verbose_selector_cursor + 1) % 4;
                    self.needs_redraw = true;
                }
                KeyCode::Enter => {
                    let mode = [
                        ToolProgressMode::Off,
                        ToolProgressMode::New,
                        ToolProgressMode::All,
                        ToolProgressMode::Verbose,
                    ][self.verbose_selector_cursor];
                    let msg = self.set_tool_progress_mode_explicit(mode);
                    self.verbose_selector_active = false;
                    self.push_output(msg, OutputRole::System);
                    self.needs_redraw = true;
                }
                _ => {}
            }
            return;
        }

        // Model selector overlay active — intercept all keys
        if self.model_selector.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::ModelSelector) => {
                    self.model_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::ModelSelector,
                        self.split_detail_scroll(DetailSurface::ModelSelector),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::ModelSelector) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::ModelSelector);
                }
                KeyCode::Enter => {
                    if let Some(model) = self.model_selector.current().map(|e| e.display.clone()) {
                        self.model_selector.active = false;
                        self.close_detail_fullscreen(DetailSurface::ModelSelector);
                        match self.model_selector_target {
                            ModelSelectorTarget::Primary => self.handle_model_switch(model),
                            ModelSelectorTarget::Cheap => self.handle_set_cheap_model(model),
                            ModelSelectorTarget::MoaAggregator => {
                                self.handle_set_moa_aggregator(model);
                            }
                        }
                    }
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::ModelSelector) {
                        self.scroll_split_detail_lines(DetailSurface::ModelSelector, -1);
                    } else {
                        self.model_selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::ModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ModelSelector);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::ModelSelector) {
                        self.scroll_split_detail_lines(DetailSurface::ModelSelector, 1);
                    } else {
                        self.model_selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::ModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ModelSelector);
                    }
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::ModelSelector) => {
                    self.page_up_detail_fullscreen(DetailSurface::ModelSelector);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::ModelSelector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::ModelSelector);
                }
                KeyCode::PageUp => self
                    .apply_simple_selector_paging(DetailSurface::ModelSelector, PagingIntent::Up),
                KeyCode::PageDown => self
                    .apply_simple_selector_paging(DetailSurface::ModelSelector, PagingIntent::Down),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::ModelSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::ModelSelector, false);
                    } else {
                        self.model_selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::ModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ModelSelector);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::ModelSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::ModelSelector, true);
                    } else {
                        self.model_selector.selected =
                            self.model_selector.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::ModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ModelSelector);
                    }
                }
                KeyCode::Backspace => {
                    self.model_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::ModelSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ModelSelector);
                }
                KeyCode::Char(c)
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
                {
                    self.model_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::ModelSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ModelSelector);
                }
                _ => {}
            }
            return;
        }

        if self.gateway_browser.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::GatewayBrowser) => {
                    self.gateway_browser.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::GatewayBrowser,
                        self.gateway_browser_pane.scroll,
                    );
                }
                KeyCode::Enter => {
                    self.open_gateway_primary_editor();
                }
                KeyCode::Char(' ') => {
                    self.toggle_selected_gateway_platform();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                _ if selector_action_key(&key, 'a') => {
                    self.open_gateway_allowlist_editor();
                }
                _ if selector_action_key(&key, 'h') => {
                    self.open_gateway_home_channel_editor();
                }
                _ if selector_action_key(&key, 'b') => {
                    self.open_gateway_bind_editor();
                }
                _ if selector_action_key(&key, 'r') => {
                    self.refresh_gateway_browser();
                }
                _ if selector_action_key(&key, 'x') => {
                    self.handle_gateway_control("restart".into());
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::GatewayBrowser) =>
                {
                    self.gateway_browser_pane.focus = self.gateway_browser_pane.focus.toggle();
                }
                KeyCode::Up => {
                    self.gateway_browser.move_up();
                    self.gateway_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                KeyCode::Down => {
                    self.gateway_browser.move_down();
                    self.gateway_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::GatewayBrowser) => {
                    self.page_up_detail_fullscreen(DetailSurface::GatewayBrowser);
                }
                KeyCode::PageUp => {
                    if self.gateway_browser_pane.focus == SplitPaneFocus::Detail {
                        self.gateway_browser_pane.page_up(8);
                    } else {
                        self.gateway_browser.page_up();
                        self.gateway_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                    }
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::GatewayBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::GatewayBrowser);
                }
                KeyCode::PageDown => {
                    if self.gateway_browser_pane.focus == SplitPaneFocus::Detail {
                        self.gateway_browser_pane.page_down(8);
                    } else {
                        self.gateway_browser.page_down();
                        self.gateway_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                    }
                }
                KeyCode::Home => {
                    self.gateway_browser.selected = 0;
                    self.gateway_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                KeyCode::End => {
                    self.gateway_browser.selected =
                        self.gateway_browser.filtered.len().saturating_sub(1);
                    self.gateway_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                KeyCode::Backspace => {
                    self.gateway_browser.pop_char();
                    self.gateway_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.gateway_browser.push_char(c);
                    self.gateway_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::GatewayBrowser);
                }
                _ => {}
            }
            self.needs_redraw = true;
            return;
        }

        if self.moa_reference_selector.active {
            match key.code {
                KeyCode::Esc => {
                    self.moa_reference_selector.active = false;
                }
                KeyCode::Enter => {
                    self.moa_reference_selector.active = false;
                    match self.moa_reference_selector_mode {
                        MoaReferenceSelectorMode::EditRoster => {
                            let selected: Vec<String> =
                                self.moa_reference_selected.iter().cloned().collect();
                            self.handle_save_moa_reference_selection(selected);
                        }
                        MoaReferenceSelectorMode::AddExpert => {
                            if let Some(model) = self
                                .moa_reference_selector
                                .current()
                                .map(|entry| entry.display.clone())
                            {
                                self.handle_add_moa_reference(model);
                            }
                        }
                        MoaReferenceSelectorMode::RemoveExpert => {
                            if let Some(model) = self
                                .moa_reference_selector
                                .current()
                                .map(|entry| entry.display.clone())
                            {
                                self.handle_remove_moa_reference(model);
                            }
                        }
                    }
                }
                KeyCode::Char(' ')
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
                        && self.moa_reference_selector_mode
                            == MoaReferenceSelectorMode::EditRoster =>
                {
                    if let Some(model) = self
                        .moa_reference_selector
                        .current()
                        .map(|entry| entry.display.clone())
                    {
                        if !self.moa_reference_selected.insert(model.clone()) {
                            self.moa_reference_selected.remove(&model);
                        }
                        self.needs_redraw = true;
                    }
                }
                KeyCode::Tab => self.moa_reference_selector.move_down(),
                KeyCode::BackTab => self.moa_reference_selector.move_up(),
                KeyCode::Up => self.moa_reference_selector.move_up(),
                KeyCode::Down => self.moa_reference_selector.move_down(),
                KeyCode::PageUp => self.moa_reference_selector.page_up(),
                KeyCode::PageDown => self.moa_reference_selector.page_down(),
                KeyCode::Backspace => self.moa_reference_selector.pop_char(),
                KeyCode::Char(c)
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
                {
                    self.moa_reference_selector.push_char(c);
                }
                _ => {}
            }
            return;
        }

        // Vision-model selector overlay active — same navigation as /model.
        if self.vision_model_selector.active {
            match key.code {
                KeyCode::Esc
                    if !self.close_detail_fullscreen(DetailSurface::VisionModelSelector) =>
                {
                    self.vision_model_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::VisionModelSelector,
                        self.split_detail_scroll(DetailSurface::VisionModelSelector),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::VisionModelSelector) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::VisionModelSelector);
                }
                KeyCode::Enter => {
                    if let Some(model) = self
                        .vision_model_selector
                        .current()
                        .map(|entry| entry.display.clone())
                    {
                        self.vision_model_selector.active = false;
                        self.close_detail_fullscreen(DetailSurface::VisionModelSelector);
                        self.handle_set_vision_model(model);
                    }
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::VisionModelSelector) {
                        self.scroll_split_detail_lines(DetailSurface::VisionModelSelector, -1);
                    } else {
                        self.vision_model_selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::VisionModelSelector);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::VisionModelSelector) {
                        self.scroll_split_detail_lines(DetailSurface::VisionModelSelector, 1);
                    } else {
                        self.vision_model_selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::VisionModelSelector);
                    }
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::VisionModelSelector) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::VisionModelSelector);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::VisionModelSelector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::VisionModelSelector);
                }
                KeyCode::PageUp => self.apply_simple_selector_paging(
                    DetailSurface::VisionModelSelector,
                    PagingIntent::Up,
                ),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::VisionModelSelector,
                    PagingIntent::Down,
                ),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::VisionModelSelector) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::VisionModelSelector,
                            false,
                        );
                    } else {
                        self.vision_model_selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::VisionModelSelector);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::VisionModelSelector) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::VisionModelSelector,
                            true,
                        );
                    } else {
                        self.vision_model_selector.selected =
                            self.vision_model_selector.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::VisionModelSelector);
                    }
                }
                KeyCode::Backspace => {
                    self.vision_model_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::VisionModelSelector);
                }
                KeyCode::Char(c)
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
                {
                    self.vision_model_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::VisionModelSelector);
                }
                _ => {}
            }
            return;
        }

        if self.image_model_selector.active {
            match key.code {
                KeyCode::Esc
                    if !self.close_detail_fullscreen(DetailSurface::ImageModelSelector) =>
                {
                    self.image_model_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::ImageModelSelector,
                        self.split_detail_scroll(DetailSurface::ImageModelSelector),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::ImageModelSelector) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::ImageModelSelector);
                }
                KeyCode::Enter => {
                    if let Some(model) = self
                        .image_model_selector
                        .current()
                        .map(|entry| entry.display.clone())
                    {
                        self.image_model_selector.active = false;
                        self.close_detail_fullscreen(DetailSurface::ImageModelSelector);
                        self.handle_set_image_model(model);
                    }
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::ImageModelSelector) {
                        self.scroll_split_detail_lines(DetailSurface::ImageModelSelector, -1);
                    } else {
                        self.image_model_selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ImageModelSelector);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::ImageModelSelector) {
                        self.scroll_split_detail_lines(DetailSurface::ImageModelSelector, 1);
                    } else {
                        self.image_model_selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ImageModelSelector);
                    }
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::ImageModelSelector) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::ImageModelSelector);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::ImageModelSelector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::ImageModelSelector);
                }
                KeyCode::PageUp => self.apply_simple_selector_paging(
                    DetailSurface::ImageModelSelector,
                    PagingIntent::Up,
                ),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::ImageModelSelector,
                    PagingIntent::Down,
                ),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::ImageModelSelector) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::ImageModelSelector,
                            false,
                        );
                    } else {
                        self.image_model_selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ImageModelSelector);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::ImageModelSelector) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::ImageModelSelector,
                            true,
                        );
                    } else {
                        self.image_model_selector.selected =
                            self.image_model_selector.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ImageModelSelector);
                    }
                }
                KeyCode::Backspace => {
                    self.image_model_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ImageModelSelector);
                }
                KeyCode::Char(c)
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
                {
                    self.image_model_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ImageModelSelector);
                }
                _ => {}
            }
            return;
        }

        // MCP selector overlay active — mirrors /model UX while keeping
        // installs controlled. Catalog-only entries open detail view instead.
        if self.mcp_selector.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::McpSelector) => {
                    self.mcp_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::McpSelector,
                        self.split_detail_scroll(DetailSurface::McpSelector),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::McpSelector) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::McpSelector);
                }
                _ if selector_action_key(&key, 'r') => {
                    let query = self.mcp_selector.query.clone();
                    self.open_mcp_selector(Some(&query), true);
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.mcp_selector.current() {
                        let command = entry.default_command();
                        self.mcp_selector.active = false;
                        self.close_detail_fullscreen(DetailSurface::McpSelector);
                        self.handle_mcp_command(command);
                    }
                }
                KeyCode::Delete => {
                    if let Some(entry) = self.mcp_selector.current()
                        && let Some(command) = entry.remove_command()
                    {
                        self.mcp_selector.active = false;
                        self.handle_mcp_command(command);
                    }
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::McpSelector) {
                        self.scroll_split_detail_lines(DetailSurface::McpSelector, -1);
                    } else {
                        self.mcp_selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::McpSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::McpSelector);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::McpSelector) {
                        self.scroll_split_detail_lines(DetailSurface::McpSelector, 1);
                    } else {
                        self.mcp_selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::McpSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::McpSelector);
                    }
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::McpSelector) => {
                    self.page_up_detail_fullscreen(DetailSurface::McpSelector);
                }
                KeyCode::PageDown if self.detail_fullscreen_active(DetailSurface::McpSelector) => {
                    self.page_down_detail_fullscreen(DetailSurface::McpSelector);
                }
                KeyCode::PageUp => {
                    self.apply_simple_selector_paging(DetailSurface::McpSelector, PagingIntent::Up)
                }
                KeyCode::PageDown => self
                    .apply_simple_selector_paging(DetailSurface::McpSelector, PagingIntent::Down),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::McpSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::McpSelector, false);
                    } else {
                        self.mcp_selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::McpSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::McpSelector);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::McpSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::McpSelector, true);
                    } else {
                        self.mcp_selector.selected =
                            self.mcp_selector.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::McpSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::McpSelector);
                    }
                }
                KeyCode::Backspace => {
                    self.mcp_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::McpSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::McpSelector);
                }
                KeyCode::Char(' ') => {
                    if let Some(entry) = self.mcp_selector.current()
                        && let Some(command) = entry.toggle_command()
                    {
                        let query = self.mcp_selector.query.clone();
                        self.handle_mcp_command(command);
                        self.open_mcp_selector(Some(&query), false);
                    }
                }
                _ if selector_action_key(&key, 'v') => {
                    if let Some(entry) = self.mcp_selector.current() {
                        let command = entry.view_command();
                        self.mcp_selector.active = false;
                        self.handle_mcp_command(command);
                    }
                }
                _ if selector_action_key(&key, 'i') => {
                    if let Some(entry) = self.mcp_selector.current()
                        && let Some(command) = entry.install_command()
                    {
                        self.mcp_selector.active = false;
                        self.handle_mcp_command(command);
                    }
                }
                _ if selector_action_key(&key, 't') => {
                    if let Some(entry) = self.mcp_selector.current()
                        && entry.kind == McpEntryKind::ConfiguredServer
                    {
                        let command = entry.test_command();
                        self.mcp_selector.active = false;
                        self.handle_mcp_command(command);
                    }
                }
                _ if selector_action_key(&key, 'c') => {
                    if let Some(entry) = self.mcp_selector.current()
                        && entry.kind == McpEntryKind::ConfiguredServer
                    {
                        let command = entry.doctor_command();
                        self.mcp_selector.active = false;
                        self.handle_mcp_command(command);
                    }
                }
                _ if selector_action_key(&key, 'd') => {
                    if let Some(entry) = self.mcp_selector.current()
                        && let Some(command) = entry.remove_command()
                    {
                        self.mcp_selector.active = false;
                        self.handle_mcp_command(command);
                    }
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.mcp_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::McpSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::McpSelector);
                }
                _ => {}
            }
            return;
        }

        if self.remote_mcp_browser.selector.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::RemoteMcpBrowser) => {
                    self.remote_mcp_browser.selector.active = false;
                    self.needs_redraw = true;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::RemoteMcpBrowser,
                        self.split_detail_scroll(DetailSurface::RemoteMcpBrowser),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::RemoteMcpBrowser) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::RemoteMcpBrowser);
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.remote_mcp_browser.selector.current().cloned() {
                        match entry.action() {
                            RemoteMcpAction::Install => self.install_remote_mcp_entry(&entry),
                            RemoteMcpAction::View => self.view_remote_mcp_entry(&entry),
                        }
                    }
                }
                _ if selector_action_key(&key, 'i') => {
                    if let Some(entry) = self.remote_mcp_browser.selector.current().cloned() {
                        if entry.install.is_some() {
                            self.install_remote_mcp_entry(&entry);
                        } else {
                            self.push_output(
                                "This remote MCP entry is view-only. Use Enter or V to inspect it.",
                                OutputRole::System,
                            );
                        }
                    }
                }
                _ if selector_action_key(&key, 'v') => {
                    if let Some(entry) = self.remote_mcp_browser.selector.current().cloned() {
                        self.view_remote_mcp_entry(&entry);
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    self.schedule_remote_mcp_search(true);
                }
                _ if selector_action_key(&key, 'l') => {
                    self.remote_mcp_browser.selector.active = false;
                    self.close_detail_fullscreen(DetailSurface::RemoteMcpBrowser);
                    self.open_mcp_selector(None, false);
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteMcpBrowser) {
                        self.scroll_split_detail_lines(DetailSurface::RemoteMcpBrowser, -1);
                    } else {
                        self.remote_mcp_browser.selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteMcpBrowser) {
                        self.scroll_split_detail_lines(DetailSurface::RemoteMcpBrowser, 1);
                    } else {
                        self.remote_mcp_browser.selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser);
                    }
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::RemoteMcpBrowser) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::RemoteMcpBrowser);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::RemoteMcpBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::RemoteMcpBrowser);
                }
                KeyCode::PageUp => self.apply_simple_selector_paging(
                    DetailSurface::RemoteMcpBrowser,
                    PagingIntent::Up,
                ),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::RemoteMcpBrowser,
                    PagingIntent::Down,
                ),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteMcpBrowser) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::RemoteMcpBrowser,
                            false,
                        );
                    } else {
                        self.remote_mcp_browser.selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteMcpBrowser) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::RemoteMcpBrowser, true);
                    } else {
                        self.remote_mcp_browser.selector.selected = self
                            .remote_mcp_browser
                            .selector
                            .filtered
                            .len()
                            .saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser);
                    }
                }
                KeyCode::Backspace => {
                    self.remote_mcp_browser.selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
                    self.reset_detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser);
                    self.schedule_remote_mcp_search(false);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.remote_mcp_browser.selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::RemoteMcpBrowser);
                    self.reset_detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser);
                    self.schedule_remote_mcp_search(false);
                }
                _ => {}
            }
            return;
        }

        if self.remote_skill_browser.selector.active {
            match key.code {
                KeyCode::Esc
                    if !self.close_detail_fullscreen(DetailSurface::RemoteSkillBrowser) =>
                {
                    self.remote_skill_browser.selector.active = false;
                    self.remote_skill_browser.action_in_flight = None;
                    self.needs_redraw = true;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::RemoteSkillBrowser,
                        self.split_detail_scroll(DetailSurface::RemoteSkillBrowser),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::RemoteSkillBrowser) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::RemoteSkillBrowser);
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.remote_skill_browser.selector.current().cloned() {
                        self.run_remote_skill_action(entry);
                    }
                }
                _ if selector_action_key(&key, 'i') => {
                    if let Some(entry) = self.remote_skill_browser.selector.current().cloned() {
                        self.run_remote_skill_action(entry);
                    }
                }
                _ if selector_action_key(&key, 'u') => {
                    if let Some(mut entry) = self.remote_skill_browser.selector.current().cloned() {
                        if entry.installed_name.is_some() {
                            entry.action = RemoteSkillAction::Update;
                            self.run_remote_skill_action(entry);
                        } else {
                            self.push_output(
                                "This remote skill is not hub-installed yet. Use Enter or I to install it.",
                                OutputRole::System,
                            );
                        }
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    self.schedule_remote_skill_search(true);
                }
                _ if selector_action_key(&key, 'l') => {
                    self.remote_skill_browser.selector.active = false;
                    self.close_detail_fullscreen(DetailSurface::RemoteSkillBrowser);
                    self.refresh_skills_list();
                    self.open_skill_selector();
                    self.needs_redraw = true;
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteSkillBrowser) {
                        self.scroll_split_detail_lines(DetailSurface::RemoteSkillBrowser, -1);
                    } else {
                        self.remote_skill_browser.selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteSkillBrowser) {
                        self.scroll_split_detail_lines(DetailSurface::RemoteSkillBrowser, 1);
                    } else {
                        self.remote_skill_browser.selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser);
                    }
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::RemoteSkillBrowser) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::RemoteSkillBrowser);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::RemoteSkillBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::RemoteSkillBrowser);
                }
                KeyCode::PageUp => self.apply_simple_selector_paging(
                    DetailSurface::RemoteSkillBrowser,
                    PagingIntent::Up,
                ),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::RemoteSkillBrowser,
                    PagingIntent::Down,
                ),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteSkillBrowser) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::RemoteSkillBrowser,
                            false,
                        );
                    } else {
                        self.remote_skill_browser.selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::RemoteSkillBrowser) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::RemoteSkillBrowser,
                            true,
                        );
                    } else {
                        self.remote_skill_browser.selector.selected = self
                            .remote_skill_browser
                            .selector
                            .filtered
                            .len()
                            .saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser);
                    }
                }
                KeyCode::Backspace => {
                    self.remote_skill_browser.selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
                    self.reset_detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser);
                    self.schedule_remote_skill_search(false);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.remote_skill_browser.selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::RemoteSkillBrowser);
                    self.reset_detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser);
                    self.schedule_remote_skill_search(false);
                }
                _ => {}
            }
            return;
        }

        if self.remote_plugin_browser.selector.active {
            match key.code {
                KeyCode::Esc
                    if !self.close_detail_fullscreen(DetailSurface::RemotePluginBrowser) =>
                {
                    self.remote_plugin_browser.selector.active = false;
                    self.remote_plugin_browser.action_in_flight = None;
                    self.needs_redraw = true;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::RemotePluginBrowser,
                        self.split_detail_scroll(DetailSurface::RemotePluginBrowser),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::RemotePluginBrowser) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::RemotePluginBrowser);
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.remote_plugin_browser.selector.current().cloned() {
                        self.run_remote_plugin_action(entry);
                    }
                }
                _ if selector_action_key(&key, 'i') => {
                    if let Some(entry) = self.remote_plugin_browser.selector.current().cloned() {
                        self.run_remote_plugin_action(entry);
                    }
                }
                _ if selector_action_key(&key, 'u') => {
                    if let Some(mut entry) = self.remote_plugin_browser.selector.current().cloned()
                    {
                        if entry.installed_name.is_some() {
                            entry.action = RemotePluginAction::Update;
                            self.run_remote_plugin_action(entry);
                        } else {
                            self.push_output(
                                "This remote plugin is not hub-installed yet. Use Enter or I to install it.",
                                OutputRole::System,
                            );
                        }
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    self.schedule_remote_plugin_search(true);
                }
                _ if selector_action_key(&key, 'l') => {
                    let scope = self.plugin_toggle_scope.clone();
                    self.remote_plugin_browser.selector.active = false;
                    self.close_detail_fullscreen(DetailSurface::RemotePluginBrowser);
                    self.open_plugin_toggle(scope);
                    self.needs_redraw = true;
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::RemotePluginBrowser) {
                        self.scroll_split_detail_lines(DetailSurface::RemotePluginBrowser, -1);
                    } else {
                        self.remote_plugin_browser.selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::RemotePluginBrowser) {
                        self.scroll_split_detail_lines(DetailSurface::RemotePluginBrowser, 1);
                    } else {
                        self.remote_plugin_browser.selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser);
                    }
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::RemotePluginBrowser) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::RemotePluginBrowser);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::RemotePluginBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::RemotePluginBrowser);
                }
                KeyCode::PageUp => self.apply_simple_selector_paging(
                    DetailSurface::RemotePluginBrowser,
                    PagingIntent::Up,
                ),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::RemotePluginBrowser,
                    PagingIntent::Down,
                ),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::RemotePluginBrowser) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::RemotePluginBrowser,
                            false,
                        );
                    } else {
                        self.remote_plugin_browser.selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::RemotePluginBrowser) {
                        self.set_split_detail_scroll_to_edge(
                            DetailSurface::RemotePluginBrowser,
                            true,
                        );
                    } else {
                        self.remote_plugin_browser.selector.selected = self
                            .remote_plugin_browser
                            .selector
                            .filtered
                            .len()
                            .saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
                        self.reset_detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser);
                    }
                }
                KeyCode::Backspace => {
                    self.remote_plugin_browser.selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
                    self.reset_detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser);
                    self.schedule_remote_plugin_search(false);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.remote_plugin_browser.selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::RemotePluginBrowser);
                    self.reset_detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser);
                    self.schedule_remote_plugin_search(false);
                }
                _ => {}
            }
            return;
        }

        if self.profile_selector.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::ProfileSelector) => {
                    self.profile_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::ProfileSelector,
                        self.split_detail_scroll(DetailSurface::ProfileSelector),
                    );
                }
                KeyCode::Enter => {
                    if let Some(name) = self
                        .profile_selector
                        .current()
                        .map(|entry| entry.name.clone())
                    {
                        self.handle_profile_switch(name);
                    }
                }
                _ if selector_action_key(&key, 'v') => {
                    self.set_profile_detail_mode(ProfileDetailMode::Summary);
                }
                _ if selector_action_key(&key, 'c') => {
                    self.set_profile_detail_mode(ProfileDetailMode::Config);
                }
                _ if selector_action_key(&key, 's') => {
                    self.set_profile_detail_mode(ProfileDetailMode::Soul);
                }
                _ if selector_action_key(&key, 'm') => {
                    self.set_profile_detail_mode(ProfileDetailMode::Memory);
                }
                _ if selector_action_key(&key, 't') => {
                    self.set_profile_detail_mode(ProfileDetailMode::Tools);
                }
                _ if selector_action_key(&key, 'h') || matches!(key.code, KeyCode::Char('?')) => {
                    self.set_profile_detail_mode(ProfileDetailMode::Help);
                }
                _ if selector_action_key(&key, 'a') => {
                    if let Some(name) = self
                        .profile_selector
                        .current()
                        .map(|entry| entry.name.clone())
                    {
                        self.open_profile_alias_editor(name);
                    }
                }
                _ if selector_action_key(&key, 'e') => {
                    if let Some(name) = self
                        .profile_selector
                        .current()
                        .map(|entry| entry.name.clone())
                    {
                        self.open_profile_export_editor(name);
                    }
                }
                _ if selector_action_key(&key, 'd') || selector_action_key(&key, 'x') => {
                    if let Some(name) = self
                        .profile_selector
                        .current()
                        .map(|entry| entry.name.clone())
                    {
                        self.open_profile_delete_editor(name);
                    }
                }
                _ if selector_action_key(&key, 'n') => {
                    self.open_profile_create_editor();
                }
                _ if selector_action_key(&key, 'i') => {
                    self.open_profile_import_editor();
                }
                _ if selector_action_key(&key, 'o') => {
                    if let Some(name) = self
                        .profile_selector
                        .current()
                        .map(|entry| entry.name.clone())
                    {
                        self.open_profile_rename_editor(name);
                    }
                }
                KeyCode::Home => {
                    self.profile_selector.selected = 0;
                    self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
                }
                KeyCode::End => {
                    self.profile_selector.selected =
                        self.profile_selector.filtered.len().saturating_sub(1);
                    self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
                }
                KeyCode::Left | KeyCode::BackTab => {
                    self.cycle_profile_detail_mode(false);
                }
                KeyCode::Right | KeyCode::Tab => {
                    self.cycle_profile_detail_mode(true);
                }
                KeyCode::Up => {
                    self.profile_selector.move_up();
                    self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
                }
                KeyCode::Down => {
                    self.profile_selector.move_down();
                    self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::ProfileSelector) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::ProfileSelector);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::ProfileSelector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::ProfileSelector);
                }
                KeyCode::PageUp => self
                    .apply_simple_selector_paging(DetailSurface::ProfileSelector, PagingIntent::Up),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::ProfileSelector,
                    PagingIntent::Down,
                ),
                _ if selector_action_key(&key, 'r') => {
                    self.refresh_profiles_list();
                }
                KeyCode::Backspace => {
                    self.profile_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.profile_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::ProfileSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ProfileSelector);
                }
                _ => {}
            }
            return;
        }

        // Skill selector overlay active — same key scheme as model selector
        if self.skill_selector.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::SkillSelector) => {
                    self.skill_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::SkillSelector,
                        self.split_detail_scroll(DetailSurface::SkillSelector),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::SkillSelector) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::SkillSelector);
                }
                KeyCode::Char(' ') => {
                    if let Some(name) = self
                        .skill_selector
                        .current()
                        .map(|entry| entry.name.clone())
                    {
                        self.set_skill_activation(
                            &name,
                            !self.active_skills.iter().any(|skill| skill == &name),
                        );
                    }
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.skill_selector.current() {
                        let skill_name = format!("/{} ", entry.name);
                        self.skill_selector.active = false;
                        self.close_detail_fullscreen(DetailSurface::SkillSelector);
                        self.textarea_set_text(&skill_name);
                        self.needs_redraw = true;
                    }
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::SkillSelector) {
                        self.scroll_split_detail_lines(DetailSurface::SkillSelector, -1);
                    } else {
                        self.skill_selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::SkillSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::SkillSelector);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::SkillSelector) {
                        self.scroll_split_detail_lines(DetailSurface::SkillSelector, 1);
                    } else {
                        self.skill_selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::SkillSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::SkillSelector);
                    }
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::SkillSelector) => {
                    self.page_up_detail_fullscreen(DetailSurface::SkillSelector);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::SkillSelector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::SkillSelector);
                }
                KeyCode::PageUp => self
                    .apply_simple_selector_paging(DetailSurface::SkillSelector, PagingIntent::Up),
                KeyCode::PageDown => self
                    .apply_simple_selector_paging(DetailSurface::SkillSelector, PagingIntent::Down),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::SkillSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::SkillSelector, false);
                    } else {
                        self.skill_selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::SkillSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::SkillSelector);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::SkillSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::SkillSelector, true);
                    } else {
                        self.skill_selector.selected =
                            self.skill_selector.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::SkillSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::SkillSelector);
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    self.open_remote_skill_selector(None);
                }
                KeyCode::Backspace => {
                    self.skill_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::SkillSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::SkillSelector);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.skill_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::SkillSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::SkillSelector);
                }
                _ => {}
            }
            return;
        }

        if self.tool_manager.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::ToolManager) => {
                    self.tool_manager.active = false;
                }
                KeyCode::Left => {
                    self.tool_manager_scope = self.tool_manager_scope.previous();
                    let _ = self.refresh_tool_manager_entries();
                    self.reset_split_detail_scroll(DetailSurface::ToolManager);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                }
                KeyCode::Right => {
                    self.tool_manager_scope = self.tool_manager_scope.next();
                    let _ = self.refresh_tool_manager_entries();
                    self.reset_split_detail_scroll(DetailSurface::ToolManager);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::ToolManager) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::ToolManager);
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::ToolManager,
                        self.split_detail_scroll(DetailSurface::ToolManager),
                    );
                }
                KeyCode::Enter | KeyCode::Char(' ') => {
                    self.toggle_tool_manager_selected();
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::ToolManager) {
                        self.scroll_split_detail_lines(DetailSurface::ToolManager, -1);
                    } else {
                        self.tool_manager.move_up();
                        self.reset_split_detail_scroll(DetailSurface::ToolManager);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::ToolManager) {
                        self.scroll_split_detail_lines(DetailSurface::ToolManager, 1);
                    } else {
                        self.tool_manager.move_down();
                        self.reset_split_detail_scroll(DetailSurface::ToolManager);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                    }
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::ToolManager) => {
                    self.page_up_detail_fullscreen(DetailSurface::ToolManager);
                }
                KeyCode::PageDown if self.detail_fullscreen_active(DetailSurface::ToolManager) => {
                    self.page_down_detail_fullscreen(DetailSurface::ToolManager);
                }
                KeyCode::PageUp => {
                    self.apply_simple_selector_paging(DetailSurface::ToolManager, PagingIntent::Up)
                }
                KeyCode::PageDown => self
                    .apply_simple_selector_paging(DetailSurface::ToolManager, PagingIntent::Down),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::ToolManager) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::ToolManager, false);
                    } else {
                        self.tool_manager.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::ToolManager);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::ToolManager) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::ToolManager, true);
                    } else {
                        self.tool_manager.selected =
                            self.tool_manager.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::ToolManager);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    self.reset_tool_manager_policy();
                }
                KeyCode::Backspace => {
                    self.tool_manager.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::ToolManager);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.tool_manager.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::ToolManager);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ToolManager);
                }
                _ => {}
            }
            return;
        }

        if self.plugin_toggle.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::PluginToggle) => {
                    self.plugin_toggle.active = false;
                }
                KeyCode::Left => {
                    self.plugin_toggle_scope = self.previous_plugin_toggle_scope();
                    let _ = self.refresh_plugin_toggle_entries();
                    self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                    self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                }
                KeyCode::Right => {
                    self.plugin_toggle_scope = self.next_plugin_toggle_scope();
                    let _ = self.refresh_plugin_toggle_entries();
                    self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                    self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::PluginToggle) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::PluginToggle);
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::PluginToggle,
                        self.split_detail_scroll(DetailSurface::PluginToggle),
                    );
                }
                KeyCode::Enter => {
                    self.confirm_plugin_toggle();
                }
                KeyCode::Char(' ') => {
                    self.toggle_plugin_selected();
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::PluginToggle) {
                        self.scroll_split_detail_lines(DetailSurface::PluginToggle, -1);
                    } else {
                        self.plugin_toggle.move_up();
                        self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                        self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::PluginToggle) {
                        self.scroll_split_detail_lines(DetailSurface::PluginToggle, 1);
                    } else {
                        self.plugin_toggle.move_down();
                        self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                        self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                    }
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::PluginToggle) => {
                    self.page_up_detail_fullscreen(DetailSurface::PluginToggle);
                }
                KeyCode::PageDown if self.detail_fullscreen_active(DetailSurface::PluginToggle) => {
                    self.page_down_detail_fullscreen(DetailSurface::PluginToggle);
                }
                KeyCode::PageUp => {
                    self.apply_simple_selector_paging(DetailSurface::PluginToggle, PagingIntent::Up)
                }
                KeyCode::PageDown => self
                    .apply_simple_selector_paging(DetailSurface::PluginToggle, PagingIntent::Down),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::PluginToggle) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::PluginToggle, false);
                    } else {
                        self.plugin_toggle.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                        self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::PluginToggle) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::PluginToggle, true);
                    } else {
                        self.plugin_toggle.selected =
                            self.plugin_toggle.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                        self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    self.open_remote_plugin_selector(None, None);
                }
                KeyCode::Backspace => {
                    self.plugin_toggle.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                    self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.plugin_toggle.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::PluginToggle);
                    self.reset_detail_fullscreen_scroll(DetailSurface::PluginToggle);
                }
                _ => {}
            }
            return;
        }

        if self.config_selector.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::ConfigSelector) => {
                    self.config_selector.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::ConfigSelector,
                        self.split_detail_scroll(DetailSurface::ConfigSelector),
                    );
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::ConfigSelector) =>
                {
                    self.toggle_simple_split_focus(DetailSurface::ConfigSelector);
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.config_selector.current() {
                        let action = entry.action;
                        self.config_selector.active = false;
                        self.close_detail_fullscreen(DetailSurface::ConfigSelector);
                        self.handle_config_selector_action(action);
                    }
                }
                KeyCode::Up => {
                    if self.simple_split_detail_focused(DetailSurface::ConfigSelector) {
                        self.scroll_split_detail_lines(DetailSurface::ConfigSelector, -1);
                    } else {
                        self.config_selector.move_up();
                        self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ConfigSelector);
                    }
                }
                KeyCode::Down => {
                    if self.simple_split_detail_focused(DetailSurface::ConfigSelector) {
                        self.scroll_split_detail_lines(DetailSurface::ConfigSelector, 1);
                    } else {
                        self.config_selector.move_down();
                        self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ConfigSelector);
                    }
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::ConfigSelector) => {
                    self.page_up_detail_fullscreen(DetailSurface::ConfigSelector);
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::ConfigSelector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::ConfigSelector);
                }
                KeyCode::PageUp => self
                    .apply_simple_selector_paging(DetailSurface::ConfigSelector, PagingIntent::Up),
                KeyCode::PageDown => self.apply_simple_selector_paging(
                    DetailSurface::ConfigSelector,
                    PagingIntent::Down,
                ),
                KeyCode::Home => {
                    if self.simple_split_detail_focused(DetailSurface::ConfigSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::ConfigSelector, false);
                    } else {
                        self.config_selector.selected = 0;
                        self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ConfigSelector);
                    }
                }
                KeyCode::End => {
                    if self.simple_split_detail_focused(DetailSurface::ConfigSelector) {
                        self.set_split_detail_scroll_to_edge(DetailSurface::ConfigSelector, true);
                    } else {
                        self.config_selector.selected =
                            self.config_selector.filtered.len().saturating_sub(1);
                        self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
                        self.reset_detail_fullscreen_scroll(DetailSurface::ConfigSelector);
                    }
                }
                KeyCode::Backspace => {
                    self.config_selector.pop_char();
                    self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ConfigSelector);
                }
                KeyCode::Char(c)
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
                {
                    self.config_selector.push_char(c);
                    self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
                    self.reset_detail_fullscreen_scroll(DetailSurface::ConfigSelector);
                }
                _ => {}
            }
            return;
        }

        // Skin browser overlay active — select with Enter to hot-reload skin
        if self.skin_browser.active {
            match key.code {
                KeyCode::Esc => {
                    self.skin_browser.active = false;
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.skin_browser.current() {
                        let name = entry.name.clone();
                        self.skin_browser.active = false;
                        self.handle_switch_skin(name);
                    }
                }
                KeyCode::Up => self.skin_browser.move_up(),
                KeyCode::Down => self.skin_browser.move_down(),
                KeyCode::Tab => self.skin_browser.move_down(),
                KeyCode::BackTab => self.skin_browser.move_up(),
                KeyCode::PageUp => self.skin_browser.page_up(),
                KeyCode::PageDown => self.skin_browser.page_down(),
                KeyCode::Backspace => self.skin_browser.pop_char(),
                KeyCode::Char(c)
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
                {
                    self.skin_browser.push_char(c);
                }
                _ => {}
            }
            return;
        }

        if self.session_inspector.active() {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::SessionInspector) => {
                    self.close_session_inspector();
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::SessionInspector,
                        self.session_inspector.pane.scroll,
                    );
                }
                _ if selector_action_key(&key, 'b') => {
                    self.close_session_inspector();
                }
                _ if selector_action_key(&key, 'r') => {
                    if let Some(session) = self.session_inspector.session.as_ref() {
                        if session.is_live {
                            self.push_output(
                                "The current session is already active.",
                                OutputRole::System,
                            );
                        } else {
                            let session_id = session.id.clone();
                            self.session_inspector.close();
                            self.session_browser.active = false;
                            self.close_detail_fullscreen(DetailSurface::SessionInspector);
                            self.handle_resume_session(Some(session_id));
                        }
                    }
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::SessionInspector) =>
                {
                    self.session_inspector.pane.focus = self.session_inspector.pane.focus.toggle();
                }
                KeyCode::Up => {
                    self.session_inspector.selector.move_up();
                    self.session_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                }
                KeyCode::Down => {
                    self.session_inspector.selector.move_down();
                    self.session_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                }
                KeyCode::PageUp
                    if self.detail_fullscreen_active(DetailSurface::SessionInspector) =>
                {
                    self.page_up_detail_fullscreen(DetailSurface::SessionInspector);
                }
                KeyCode::PageUp => {
                    if self.session_inspector.pane.focus == SplitPaneFocus::Detail {
                        self.session_inspector.pane.page_up(8);
                    } else {
                        self.session_inspector.selector.page_up();
                        self.session_inspector.pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                    }
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::SessionInspector) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::SessionInspector);
                }
                KeyCode::PageDown => {
                    if self.session_inspector.pane.focus == SplitPaneFocus::Detail {
                        self.session_inspector.pane.page_down(8);
                    } else {
                        self.session_inspector.selector.page_down();
                        self.session_inspector.pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                    }
                }
                KeyCode::Home => {
                    self.session_inspector.selector.selected = 0;
                    self.session_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                }
                KeyCode::End => {
                    self.session_inspector.selector.selected = self
                        .session_inspector
                        .selector
                        .filtered
                        .len()
                        .saturating_sub(1);
                    self.session_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                }
                KeyCode::Backspace => {
                    self.session_inspector.selector.pop_char();
                    self.session_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.session_inspector.selector.push_char(c);
                    self.session_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionInspector);
                }
                _ => {}
            }
            self.needs_redraw = true;
            return;
        }

        if self.log_inspector.active() {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::LogInspector) => {
                    self.close_log_inspector();
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::LogInspector,
                        self.log_inspector.pane.scroll,
                    );
                }
                _ if selector_action_key(&key, 'b') => {
                    self.close_log_inspector();
                }
                _ if selector_action_key(&key, 'r') => {
                    self.refresh_log_inspector();
                }
                _ if selector_action_key(&key, 'f') => {
                    self.toggle_log_follow();
                }
                KeyCode::Char(_) if log_level_shortcut(&key).is_some() => {
                    if let Some(level) = log_level_shortcut(&key) {
                        self.apply_log_level(level);
                    }
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::LogInspector) =>
                {
                    self.log_inspector.pane.focus = self.log_inspector.pane.focus.toggle();
                }
                KeyCode::Up => {
                    self.log_inspector.selector.move_up();
                    self.log_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                }
                KeyCode::Down => {
                    self.log_inspector.selector.move_down();
                    self.log_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::LogInspector) => {
                    self.page_up_detail_fullscreen(DetailSurface::LogInspector);
                }
                KeyCode::PageUp => {
                    if self.log_inspector.pane.focus == SplitPaneFocus::Detail {
                        self.log_inspector.pane.page_up(8);
                    } else {
                        self.log_inspector.selector.page_up();
                        self.log_inspector.pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                    }
                }
                KeyCode::PageDown if self.detail_fullscreen_active(DetailSurface::LogInspector) => {
                    self.page_down_detail_fullscreen(DetailSurface::LogInspector);
                }
                KeyCode::PageDown => {
                    if self.log_inspector.pane.focus == SplitPaneFocus::Detail {
                        self.log_inspector.pane.page_down(8);
                    } else {
                        self.log_inspector.selector.page_down();
                        self.log_inspector.pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                    }
                }
                KeyCode::Home => {
                    self.log_inspector.selector.selected = 0;
                    self.log_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                }
                KeyCode::End => {
                    self.log_inspector.selector.selected =
                        self.log_inspector.selector.filtered.len().saturating_sub(1);
                    self.log_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                }
                KeyCode::Backspace => {
                    self.log_inspector.selector.pop_char();
                    self.log_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.log_inspector.selector.push_char(c);
                    self.log_inspector.pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
                }
                _ => {}
            }
            self.needs_redraw = true;
            return;
        }

        if self.log_browser.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::LogBrowser) => {
                    self.log_browser.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::LogBrowser,
                        self.log_browser_pane.scroll,
                    );
                }
                _ if selector_action_key(&key, 'r') => {
                    self.refresh_log_browser();
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                _ if selector_action_key(&key, 'f') => {
                    self.toggle_log_follow();
                }
                KeyCode::Char(_) if log_level_shortcut(&key).is_some() => {
                    if let Some(level) = log_level_shortcut(&key) {
                        self.apply_log_level(level);
                    }
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.log_browser.current().cloned() {
                        self.open_log_inspector(entry);
                    }
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::LogBrowser) =>
                {
                    self.log_browser_pane.focus = self.log_browser_pane.focus.toggle();
                }
                KeyCode::Up => {
                    self.log_browser.move_up();
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                KeyCode::Down => {
                    self.log_browser.move_down();
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::LogBrowser) => {
                    self.page_up_detail_fullscreen(DetailSurface::LogBrowser);
                }
                KeyCode::PageUp => {
                    if self.log_browser_pane.focus == SplitPaneFocus::Detail {
                        self.log_browser_pane.page_up(8);
                    } else {
                        self.log_browser.page_up();
                        self.log_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                    }
                }
                KeyCode::PageDown if self.detail_fullscreen_active(DetailSurface::LogBrowser) => {
                    self.page_down_detail_fullscreen(DetailSurface::LogBrowser);
                }
                KeyCode::PageDown => {
                    if self.log_browser_pane.focus == SplitPaneFocus::Detail {
                        self.log_browser_pane.page_down(8);
                    } else {
                        self.log_browser.page_down();
                        self.log_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                    }
                }
                KeyCode::Home => {
                    self.log_browser.selected = 0;
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                KeyCode::End => {
                    self.log_browser.selected = self.log_browser.filtered.len().saturating_sub(1);
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                KeyCode::Backspace => {
                    self.log_browser.pop_char();
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.log_browser.push_char(c);
                    self.log_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
                }
                _ => {}
            }
            self.needs_redraw = true;
            return;
        }

        // Session browser overlay active — search-first, explicit uppercase actions
        if self.session_browser.active {
            match key.code {
                KeyCode::Esc if !self.close_detail_fullscreen(DetailSurface::SessionBrowser) => {
                    self.session_browser.active = false;
                }
                _ if selector_action_key(&key, 'z') => {
                    self.toggle_detail_fullscreen(
                        DetailSurface::SessionBrowser,
                        self.session_browser_pane.scroll,
                    );
                }
                KeyCode::Enter => {
                    if let Some(entry) = self.session_browser.current().cloned() {
                        self.close_detail_fullscreen(DetailSurface::SessionBrowser);
                        self.open_session_inspector(entry);
                    }
                }
                _ if selector_action_key(&key, 'r') => {
                    if let Some(entry) = self.session_browser.current() {
                        let session_id = entry.id.clone();
                        self.session_browser.active = false;
                        self.close_detail_fullscreen(DetailSurface::SessionBrowser);
                        self.handle_resume_session(Some(session_id));
                    }
                }
                _ if selector_action_key(&key, 'd') => {
                    if let Some(session_id) =
                        self.session_browser.current().map(|entry| entry.id.clone())
                    {
                        self.delete_session_from_browser(&session_id);
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    }
                }
                KeyCode::Tab | KeyCode::BackTab
                    if !self.detail_fullscreen_active(DetailSurface::SessionBrowser) =>
                {
                    self.session_browser_pane.focus = self.session_browser_pane.focus.toggle();
                }
                KeyCode::Up => {
                    self.session_browser.move_up();
                    self.session_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                }
                KeyCode::Down => {
                    self.session_browser.move_down();
                    self.session_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                }
                KeyCode::PageUp if self.detail_fullscreen_active(DetailSurface::SessionBrowser) => {
                    self.page_up_detail_fullscreen(DetailSurface::SessionBrowser);
                }
                KeyCode::PageUp => {
                    if self.session_browser_pane.focus == SplitPaneFocus::Detail {
                        self.session_browser_pane.page_up(8);
                    } else {
                        self.session_browser.page_up();
                        self.session_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    }
                }
                KeyCode::PageDown
                    if self.detail_fullscreen_active(DetailSurface::SessionBrowser) =>
                {
                    self.page_down_detail_fullscreen(DetailSurface::SessionBrowser);
                }
                KeyCode::PageDown => {
                    if self.session_browser_pane.focus == SplitPaneFocus::Detail {
                        self.session_browser_pane.page_down(8);
                    } else {
                        self.session_browser.page_down();
                        self.session_browser_pane.reset_scroll();
                        self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    }
                }
                KeyCode::Home => {
                    self.session_browser.selected = 0;
                    self.session_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                }
                KeyCode::End => {
                    self.session_browser.selected =
                        self.session_browser.filtered.len().saturating_sub(1);
                    self.session_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                }
                KeyCode::Backspace => {
                    self.session_browser.pop_char();
                    self.session_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    self.refresh_session_browser();
                }
                KeyCode::Char(c) if selector_search_char(&key) == Some(c) => {
                    self.session_browser.push_char(c);
                    self.session_browser_pane.reset_scroll();
                    self.reset_detail_fullscreen_scroll(DetailSurface::SessionBrowser);
                    self.refresh_session_browser();
                }
                _ => {}
            }
            self.needs_redraw = true;
            return;
        }

        // Completion overlay active — intercept Tab, Enter, Escape, arrows
        if self.completion.active {
            match key.code {
                KeyCode::Tab => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected =
                            (self.completion.selected + 1) % self.completion.candidates.len();
                    }
                    return;
                }
                KeyCode::BackTab => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected = if self.completion.selected == 0 {
                            self.completion.candidates.len() - 1
                        } else {
                            self.completion.selected - 1
                        };
                    }
                    return;
                }
                KeyCode::Up => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected = if self.completion.selected == 0 {
                            self.completion.candidates.len() - 1
                        } else {
                            self.completion.selected - 1
                        };
                    }
                    return;
                }
                KeyCode::Down => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected =
                            (self.completion.selected + 1) % self.completion.candidates.len();
                    }
                    return;
                }
                KeyCode::PageUp => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected = self.completion.selected.saturating_sub(8);
                    }
                    return;
                }
                KeyCode::PageDown => {
                    if !self.completion.candidates.is_empty() {
                        let last = self.completion.candidates.len() - 1;
                        self.completion.selected = (self.completion.selected + 8).min(last);
                    }
                    return;
                }
                KeyCode::Home => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected = 0;
                    }
                    return;
                }
                KeyCode::End => {
                    if !self.completion.candidates.is_empty() {
                        self.completion.selected = self.completion.candidates.len() - 1;
                    }
                    return;
                }
                KeyCode::Enter => {
                    self.accept_completion();
                    return;
                }
                KeyCode::Esc => {
                    self.completion.active = false;
                    return;
                }
                _ => {
                    // Any other key deactivates completion and falls through
                    self.completion.active = false;
                }
            }
        }

        match (key.modifiers, key.code) {
            // Page Up/Down — scroll output by viewport height
            (_, KeyCode::PageUp) if self.editor_mode.is_compose() => {
                self.textarea.scroll(Scrolling::PageUp);
                self.needs_redraw = true;
                return;
            }
            (_, KeyCode::PageDown) if self.editor_mode.is_compose() => {
                self.textarea.scroll(Scrolling::PageDown);
                self.needs_redraw = true;
                return;
            }
            (_, KeyCode::PageUp) => {
                let page = self.output_area_height.max(3).saturating_sub(2);
                self.scroll_output(page as i32);
                return;
            }
            (_, KeyCode::PageDown) => {
                let page = self.output_area_height.max(3).saturating_sub(2);
                self.scroll_output(-(page as i32));
                return;
            }
            _ => {}
        }

        match self.editor_mode {
            InputEditorMode::Inline => self.handle_inline_input_key(key),
            InputEditorMode::ComposeInsert => self.handle_compose_insert_key(key),
            InputEditorMode::ComposeNormal => self.handle_compose_normal_key(key),
        }
    }

    /// Process submitted input — either slash command or agent prompt.
    fn process_input(&mut self, input: &str) {
        // If the agent is waiting for a clarifying answer, route this input
        // directly back to the waiting tool instead of starting a new prompt.
        if let Some(tx) = self.clarify_pending_tx.take() {
            self.push_output(format!("> {}", input), OutputRole::User);
            let _ = tx.send(input.to_string());
            // Restore normal input border label now that the clarify reply is sent.
            self.textarea.set_block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(self.theme.input_border)
                    .title(format!(" {} ", self.theme.prompt_symbol.trim())),
            );
            // The agent task is now unblocked and will resume processing;
            // is_processing is still true so the spinner stays active.
            self.display_state = DisplayState::AwaitingFirstToken {
                frame: 0,
                started: Instant::now(),
            };
            return;
        }

        // Try slash command first
        if let Some(result) = self.commands.dispatch(input) {
            self.handle_command_result(result, input);
            return;
        }

        // Unrecognised '/token' — check whether it names a known skill.
        // Skills are activated/deactivated by typing their name with a leading
        // slash, e.g. `/arxiv-impact-ranking`.  This keeps the command registry
        // (Single Responsibility) free of skill-discovery logic.
        //
        // Supports an optional inline query:  `/skill_name question here`
        // — the skill is activated first, then the inline text is submitted to
        // the agent immediately (with skill context injected).  This lets users
        // write both activation and question in a single Enter-press.
        if input.starts_with('/') {
            let cmd_name = input
                .strip_prefix('/')
                .unwrap_or("")
                .split_whitespace()
                .next()
                .unwrap_or("");
            if !cmd_name.is_empty() && self.skills_completion_names.contains(&cmd_name.to_string())
            {
                // Text after `/skill_name` (may be empty)
                let inline_query = input[1 + cmd_name.len()..].trim().to_string();
                self.activate_skill(cmd_name);
                if !inline_query.is_empty() {
                    // Re-enter process_input with just the query text so the
                    // full agent-dispatch path runs (output, is_processing check,
                    // build_prompt_with_skills, streaming) without duplication.
                    self.process_input(&inline_query);
                }
                return;
            }
            self.push_output(
                format!("Unknown command: /{cmd_name}. Type /help for commands or /skills to browse skills."),
                OutputRole::System,
            );
            return;
        }

        // Regular prompt — show it in output and dispatch to agent.
        // Internally generated stop-hook follow-ups are hidden from the visible
        // transcript so the TUI stays focused on real user input.
        let auto_stop_prompt = input.strip_prefix(STOP_HOOK_AUTO_PREFIX).map(str::trim);
        if auto_stop_prompt.is_none() {
            self.stop_hook_retry_count = 0;
            self.push_output(format!("> {}", input), OutputRole::User);
        } else {
            self.push_output(
                "🪝 Resuming automatically after a local stop-hook review...",
                OutputRole::System,
            );
        }

        let agent = match self.agent.clone() {
            Some(a) => a,
            None => {
                self.push_output(
                    "No agent configured. Run with a provider to enable chat.",
                    OutputRole::Error,
                );
                return;
            }
        };

        if self.is_processing {
            self.push_output("Still processing previous request...", OutputRole::System);
            return;
        }
        self.is_processing = true;
        self.reasoning_line = None;
        // Reset per-turn streaming counters.
        self.in_flight_tool_count = 0;
        self.active_subagents.clear();
        self.turn_stream_tokens = 0;
        self.hidden_tool_calls.clear();
        self.active_tools.clear();
        self.seen_tool_signatures.clear();
        // Reset per-turn TTFB accumulator — a fresh submit starts fresh measurement.
        self.last_ttfb_secs = None;
        // Reset the response accumulator for the new turn (voice mode uses it).
        self.last_agent_response_text.clear();
        self.buffered_assistant_output.clear();
        self.last_run_outcome = None;
        self.display_state = DisplayState::AwaitingFirstToken {
            frame: 0,
            started: Instant::now(),
        };

        let tx = self.response_tx.clone();
        // Build the enriched prompt: active skill contexts are prepended
        // silently (the display above already shows the raw input to the user).
        //
        // If clipboard images are pending, append vision_analyze instructions
        // so the agent automatically processes the attached image(s).
        let mut effective_input = auto_stop_prompt.unwrap_or(input).to_string();
        if !self.pending_images.is_empty() {
            let image_paths: Vec<String> = self
                .pending_images
                .drain(..)
                .map(|p| p.display().to_string())
                .collect();
            let path_refs: Vec<&str> = image_paths.iter().map(|s| s.as_str()).collect();
            effective_input = format!(
                "{input}\n\n{block}",
                input = input,
                block = format_image_attachment_block(&path_refs)
            );
        }
        let prompt = effective_input;
        self.ensure_hook_registry_loaded();
        let hook_registry_clone = self.hook_registry.clone();
        tracing::info!(
            prompt_len = prompt.len(),
            "TUI: dispatching prompt to agent task"
        );
        let request_task = self.rt_handle.spawn(async move {
            tracing::info!("TUI→agent: outer task started on tokio worker");
            let (chunk_tx, chunk_rx) =
                tokio::sync::mpsc::unbounded_channel::<edgecrab_core::agent::StreamEvent>();

            let agent_clone = Arc::clone(&agent);
            let agent_for_task = Arc::clone(&agent);
            let prompt_clone = prompt.clone();
            let hook_registry = hook_registry_clone;
            let agent_task = tokio::spawn(async move {
                tracing::info!("TUI→agent: inner agent_task started, calling chat_streaming");
                let result = agent_for_task.chat_streaming(&prompt_clone, chunk_tx).await;
                tracing::info!(?result, "TUI→agent: chat_streaming returned");
                result
            });
            forward_agent_stream_to_tui(agent_clone, chunk_rx, agent_task, tx, hook_registry).await;
        });
        self.active_request_abort = Some(request_task.abort_handle());
    }

    /// Handle a CommandResult from the slash command registry.
    fn handle_command_result(&mut self, result: CommandResult, _input: &str) {
        match result {
            CommandResult::Output(text) => {
                self.push_output(text, OutputRole::System);
            }
            CommandResult::Clear => {
                self.start_fresh_session(None, true);
            }
            CommandResult::Exit => {
                self.should_exit = true;
            }
            CommandResult::Noop => {}
            CommandResult::ModelSwitch(model) => {
                self.handle_model_switch(model);
            }
            CommandResult::ModelSelector => {
                self.refresh_model_selector_catalog();
            }
            CommandResult::CheapModelSelector => {
                self.open_cheap_model_selector();
            }
            CommandResult::VisionModelSelector => {
                self.open_vision_model_selector();
            }
            CommandResult::ImageModelSelector => {
                self.open_image_model_selector();
            }
            CommandResult::MoaAggregatorSelector => {
                self.open_moa_aggregator_selector();
            }
            CommandResult::MoaReferenceSelector => {
                self.open_moa_reference_selector();
            }
            CommandResult::ShowCheapModel => {
                self.handle_show_cheap_model();
            }
            CommandResult::ShowVisionModel => {
                self.handle_show_vision_model();
            }
            CommandResult::SetCheapModel(spec) => {
                self.handle_set_cheap_model(spec);
            }
            CommandResult::SetVisionModel(spec) => {
                self.handle_set_vision_model(spec);
            }
            CommandResult::ShowImageModel => {
                self.handle_show_image_model();
            }
            CommandResult::SetImageModel(spec) => {
                self.handle_set_image_model(spec);
            }
            CommandResult::ShowMoaConfig => {
                self.handle_show_moa_config();
            }
            CommandResult::SetMoaEnabled(enabled) => {
                self.handle_set_moa_enabled(enabled);
            }
            CommandResult::SetMoaAggregator(spec) => {
                self.handle_set_moa_aggregator(spec);
            }
            CommandResult::AddMoaReference(spec) => {
                self.handle_add_moa_reference(spec);
            }
            CommandResult::RemoveMoaReference(spec) => {
                self.handle_remove_moa_reference(spec);
            }
            CommandResult::ResetMoaConfig => {
                self.handle_reset_moa_config();
            }
            CommandResult::ShowMcp(args) => {
                self.handle_mcp_command(args);
            }
            CommandResult::SessionNew => {
                self.start_fresh_session(Some("New session started."), false);
            }
            CommandResult::ReloadTheme => {
                self.theme = Theme::from_skin(&SkinConfig::load());
                // Update textarea style
                self.textarea.set_block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(self.theme.input_border)
                        .title(format!(" {} ", self.theme.prompt_symbol.trim())),
                );
                self.textarea.set_style(self.theme.input_text);
                self.push_output(
                    "Theme reloaded from ~/.edgecrab/skin.yaml",
                    OutputRole::System,
                );
            }
            CommandResult::Stop => {
                if self.cancel_active_request() {
                    self.push_output("Stopping current request...", OutputRole::System);
                } else {
                    self.push_output("No request is currently running.", OutputRole::System);
                }
            }
            CommandResult::Retry => {
                if let Some(agent) = self.agent.clone() {
                    let messages = self.rt_handle.block_on(async { agent.messages().await });
                    if let Some(last_user) = messages
                        .iter()
                        .rev()
                        .find(|m| m.role == edgecrab_types::Role::User)
                    {
                        let text = last_user.text_content();
                        let agent2 = agent.clone();
                        self.rt_handle
                            .block_on(async { agent2.undo_last_turn().await });
                        self.process_input(&text);
                    } else {
                        self.push_output("No previous user message to retry.", OutputRole::System);
                    }
                } else {
                    self.push_output("No agent configured.", OutputRole::Error);
                }
            }
            CommandResult::Undo => {
                if let Some(agent) = self.agent.clone() {
                    let removed = self
                        .rt_handle
                        .block_on(async { agent.undo_last_turn().await });
                    if removed > 0 {
                        self.push_output(
                            format!("Undone — removed {removed} message(s) from history."),
                            OutputRole::System,
                        );
                    } else {
                        self.push_output("Nothing to undo.", OutputRole::System);
                    }
                } else {
                    self.push_output("No agent configured.", OutputRole::Error);
                }
            }
            CommandResult::Compress => {
                if let Some(agent) = self.agent.clone() {
                    let tx = self.response_tx.clone();
                    self.display_state = DisplayState::BgOp {
                        label: "Compressing context…".to_string(),
                        frame: 0,
                        started: Instant::now(),
                    };
                    self.needs_redraw = true;
                    self.rt_handle.spawn(async move {
                        let before_messages = agent.messages().await;
                        let before_count = before_messages.len();
                        let before_tokens =
                            edgecrab_core::compression::estimate_tokens(&before_messages);
                        agent.force_compress().await;
                        let after_messages = agent.messages().await;
                        let after_count = after_messages.len();
                        let after_tokens =
                            edgecrab_core::compression::estimate_tokens(&after_messages);
                        let msg = format!(
                            "Compression done: {before_count} → {after_count} messages (~{before_tokens} → ~{after_tokens} tokens)."
                        );
                        let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::CompressDone {
                            msg,
                        }));
                    });
                } else {
                    self.push_output("No agent configured.", OutputRole::Error);
                }
            }
            CommandResult::ShowStatus => {
                self.handle_show_status();
            }
            CommandResult::ShowCost => {
                self.handle_show_cost();
            }
            CommandResult::ShowUsage => {
                self.handle_show_usage();
            }
            CommandResult::PromptCommand(args) => {
                self.handle_prompt_command(args);
            }
            CommandResult::ShowConfig(args) => {
                self.handle_config_command(args);
            }
            CommandResult::ShowHistory => {
                self.handle_show_history();
            }
            CommandResult::ToggleVerbose => {
                let msg = self.cycle_tool_progress_mode();
                self.push_output(msg, OutputRole::System);
            }
            CommandResult::SetToolProgress(mode) => {
                let msg = self.handle_tool_progress_command(mode);
                self.push_output(msg, OutputRole::System);
            }
            CommandResult::SaveSession(path) => {
                self.handle_save_session(path);
            }
            CommandResult::ExportSession(path) => {
                self.handle_export_session(path);
            }
            CommandResult::SetTitle(title) => {
                self.handle_set_title(title);
            }
            CommandResult::InspectCurrentSession => {
                self.handle_inspect_current_session();
            }
            CommandResult::SessionBrowse(query) => {
                self.handle_session_browser_command(query);
            }
            CommandResult::SessionSwitch(id) => {
                self.handle_resume_session(Some(id));
            }
            CommandResult::SessionDelete(id) => {
                self.handle_session_delete(id);
            }
            CommandResult::ResumeSession(id) => {
                self.handle_resume_session(id);
            }
            CommandResult::SessionRename(id, title) => {
                self.handle_session_rename(id, title);
            }
            CommandResult::SessionPrune(days) => {
                self.handle_session_prune(days);
            }
            CommandResult::QueuePrompt(prompt) => {
                self.prompt_queue.push(prompt.clone());
                let n = self.prompt_queue.len();
                let preview = &prompt[..prompt.len().min(60)];
                self.push_output(
                    format!("Queued ({n} pending): {preview}"),
                    OutputRole::System,
                );
            }
            CommandResult::BackgroundPrompt(prompt) => {
                self.handle_background_prompt(prompt);
            }
            CommandResult::SideQuestion(question) => {
                self.handle_side_question(question);
            }
            CommandResult::BranchSession(name) => {
                self.handle_branch_session(name);
            }
            CommandResult::ShowSkills(args) => {
                self.handle_show_skills(args);
            }
            CommandResult::ShowProfiles(args) => {
                self.handle_show_profiles(args);
            }
            CommandResult::SkillSelector => {
                self.remote_skill_browser.selector.active = false;
                self.refresh_skills_list();
                self.open_skill_selector();
            }
            CommandResult::ProfileSelector => {
                self.open_profile_selector();
            }
            CommandResult::ToolManager(mode) => {
                self.open_tool_manager(mode);
            }
            CommandResult::ResetToolPolicy => {
                self.reset_tool_manager_policy();
            }
            CommandResult::SetReasoning(level) => {
                if level.trim().is_empty() {
                    // Open the TUI picker pre-selected to the current effort level.
                    self.reasoning_selector_cursor =
                        match self.reasoning_effort_hint.as_deref().unwrap_or("medium") {
                            "low" => 0,
                            "high" => 2,
                            _ => 1, // "medium"
                        };
                    self.reasoning_selector_active = true;
                    self.needs_redraw = true;
                } else {
                    self.handle_set_reasoning(level);
                }
            }
            CommandResult::SetStreaming(mode) => {
                if mode.trim().is_empty() {
                    self.stream_selector_cursor = if self.streaming_enabled { 0 } else { 1 };
                    self.stream_selector_active = true;
                    self.needs_redraw = true;
                } else {
                    self.handle_set_streaming(mode);
                }
            }
            CommandResult::SetStatusBar(mode) => {
                if mode.trim().is_empty() {
                    self.statusbar_selector_cursor = if self.show_status_bar { 0 } else { 1 };
                    self.statusbar_selector_active = true;
                    self.needs_redraw = true;
                } else {
                    self.handle_status_bar_command(mode);
                }
            }
            CommandResult::LogCommand(args) => {
                self.handle_log_command(args);
            }
            CommandResult::WorktreeCommand(args) => {
                self.handle_worktree_command(args);
            }
            CommandResult::ListModels(filter) => {
                self.handle_list_models(filter);
            }
            CommandResult::ShowCronStatus(args) => {
                self.handle_show_cron_status(args);
            }
            CommandResult::ShowPlugins(args) => {
                self.handle_show_plugins(args);
            }
            CommandResult::ShowHooks(args) => {
                self.handle_show_hooks(args);
            }
            CommandResult::ShowPluginToggle { name, platform } => {
                if let Some(name) = name {
                    self.toggle_named_plugin(&name, platform);
                } else {
                    let scope = platform
                        .map(PluginScope::Platform)
                        .unwrap_or(PluginScope::Global);
                    self.open_plugin_toggle(scope);
                }
            }
            CommandResult::ShowPlatforms => {
                self.handle_show_platforms();
            }
            CommandResult::ShowPersonality => {
                // Open the TUI picker populated with all available personalities.
                let entries = self.personality_arg_hints();
                let cur = self.session_personality.as_deref().unwrap_or("clear");
                self.personality_selector_cursor =
                    entries.iter().position(|(k, _)| k == cur).unwrap_or(0);
                self.personality_selector_entries = entries;
                self.personality_selector_active = true;
                self.needs_redraw = true;
            }
            CommandResult::SwitchPersonality(name) => {
                self.handle_switch_personality(name);
            }
            CommandResult::SwitchSkin(name) => {
                self.handle_switch_skin(name);
            }
            CommandResult::ShowInsights(days) => {
                self.handle_show_insights(days);
            }
            CommandResult::PasteClipboard => {
                self.handle_paste_clipboard();
            }
            CommandResult::AttachImagePath(path) => {
                self.handle_paste(path);
            }
            CommandResult::AuthCommand(command) => {
                if let crate::cli_args::AuthCommand::Login { target } = &command {
                    self.run_login_target_with_terminal_handoff(
                        target.as_deref().unwrap_or("copilot"),
                    );
                } else {
                    match self
                        .rt_handle
                        .block_on(crate::auth_cmd::run_capture(command))
                    {
                        Ok(report) => self.push_output(report, OutputRole::System),
                        Err(err) => self
                            .push_output(format!("Auth command failed: {err}"), OutputRole::Error),
                    }
                }
            }
            CommandResult::LoginTarget(target) => {
                self.run_login_target_with_terminal_handoff(&target);
            }
            CommandResult::LogoutTarget(target) => {
                match self
                    .rt_handle
                    .block_on(crate::auth_cmd::logout_target_capture(target.as_deref()))
                {
                    Ok(report) => self.push_output(report, OutputRole::System),
                    Err(err) => {
                        self.push_output(format!("Logout failed: {err}"), OutputRole::Error)
                    }
                }
            }
            CommandResult::WebhookCommand(command) => {
                match self
                    .rt_handle
                    .block_on(crate::webhook_cmd::run_capture(command))
                {
                    Ok(report) => self.push_output(report, OutputRole::System),
                    Err(err) => self
                        .push_output(format!("Webhook command failed: {err}"), OutputRole::Error),
                }
            }
            CommandResult::UninstallCommand(options) => {
                match self
                    .rt_handle
                    .block_on(crate::uninstall_cmd::run_capture(options))
                {
                    Ok(report) => self.push_output(report, OutputRole::System),
                    Err(err) => {
                        self.push_output(format!("Uninstall failed: {err}"), OutputRole::Error)
                    }
                }
            }
            CommandResult::CopilotAuth => {
                self.handle_copilot_auth();
            }
            CommandResult::MouseMode(mode) => {
                self.handle_mouse_mode(mode);
            }
            CommandResult::SetYolo(mode) => {
                self.handle_set_yolo(mode);
            }
            CommandResult::ApprovalChoice(choice) => {
                self.handle_approval_choice_command(choice);
            }
            #[cfg(target_os = "macos")]
            CommandResult::MacosPermissions(args) => {
                let report = crate::permissions::run_permissions_command(&args);
                self.push_output(report, OutputRole::System);
            }
            CommandResult::RollbackCheckpoint(args) => {
                self.handle_rollback_checkpoint(args);
            }
            CommandResult::ReloadMcp => {
                self.handle_reload_mcp();
            }
            CommandResult::VoiceMode(args) => {
                self.handle_voice_mode(args);
            }
            CommandResult::McpToken(args) => {
                self.handle_mcp_token(args);
            }
            CommandResult::BrowserCommand(args) => {
                self.handle_browser_command(args);
            }
            CommandResult::SetHomeChannel(args) => {
                self.handle_set_home_channel(args);
            }
            CommandResult::GatewayControl(args) => {
                self.handle_gateway_control(args);
            }
            CommandResult::CheckUpdates => {
                self.handle_update_status();
            }
            CommandResult::ShowGatewayCommands(args) => {
                self.handle_show_gateway_commands(args);
            }
        }
    }

    fn toggle_mouse_capture_mode(&mut self) {
        let next = !self.mouse_capture_enabled;
        self.set_mouse_capture_mode(next);
    }

    fn set_mouse_capture_mode(&mut self, enabled: bool) {
        if self.mouse_capture_enabled == enabled {
            return;
        }
        self.mouse_capture_enabled = enabled;
        self.pending_mouse_capture = Some(enabled);
        self.needs_redraw = true;
        if enabled {
            self.push_output(
                "[SCROLL] Mouse capture on — wheel scrolls output. Press F6 or /mouse off to switch to SELECT mode (native drag-to-copy).",
                OutputRole::System,
            );
        } else {
            self.push_output(
                "[SELECT] Native selection active — drag to copy. Press F6 or /mouse on to switch to SCROLL mode (wheel scrolling).",
                OutputRole::System,
            );
        }
    }

    fn handle_mouse_mode(&mut self, mode: String) {
        let normalized = mode.trim().to_ascii_lowercase();
        match normalized.as_str() {
            "" | "toggle" => self.toggle_mouse_capture_mode(),
            "on" | "enable" | "enabled" => self.set_mouse_capture_mode(true),
            "off" | "disable" | "disabled" => self.set_mouse_capture_mode(false),
            "status" => {
                let text = if self.mouse_capture_enabled {
                    "Mouse mode: capture ON (wheel scrolling and click interactions enabled)."
                } else {
                    "Mouse mode: capture OFF (terminal native selection/copy mode)."
                };
                self.push_output(text, OutputRole::System);
            }
            _ => {
                self.push_output("Usage: /mouse [on|off|toggle|status]", OutputRole::System);
            }
        }
    }

    fn apply_approval_choice(&mut self, choice: edgecrab_core::ApprovalChoice) {
        let full_command =
            if let DisplayState::WaitingForApproval { full_command, .. } = &self.display_state {
                Some(full_command.clone())
            } else {
                None
            };

        if matches!(
            choice,
            edgecrab_core::ApprovalChoice::Session | edgecrab_core::ApprovalChoice::Always
        ) && let Some(full_command) = full_command.as_deref()
        {
            use std::hash::{Hash, Hasher};
            let mut hasher = std::collections::hash_map::DefaultHasher::new();
            full_command.hash(&mut hasher);
            self.session_approvals
                .insert(format!("{:x}", hasher.finish()));
        }

        if let Some(tx) = self.approval_pending_tx.take() {
            let is_deny = choice == edgecrab_core::ApprovalChoice::Deny;
            let _ = tx.send(choice);
            self.display_state = if is_deny {
                DisplayState::Idle
            } else {
                DisplayState::AwaitingFirstToken {
                    frame: 0,
                    started: Instant::now(),
                }
            };
            self.needs_redraw = true;
        }
    }

    fn handle_approval_choice_command(&mut self, choice: edgecrab_core::ApprovalChoice) {
        if self.approval_pending_tx.is_some() {
            let text = match &choice {
                edgecrab_core::ApprovalChoice::Once => "Approved current command once.",
                edgecrab_core::ApprovalChoice::Session => {
                    "Approved current command for the rest of this session."
                }
                edgecrab_core::ApprovalChoice::Always => "Approved current command permanently.",
                edgecrab_core::ApprovalChoice::Deny => "Denied current command.",
            };
            self.apply_approval_choice(choice);
            self.push_output(text, OutputRole::System);
            return;
        }

        if choice == edgecrab_core::ApprovalChoice::Deny
            && let Some(tx) = self.clarify_pending_tx.take()
        {
            let _ = tx.send(String::new());
            self.display_state = DisplayState::Idle;
            self.push_output(
                "Cancelled the pending clarification prompt.",
                OutputRole::System,
            );
            self.needs_redraw = true;
            return;
        }

        self.push_output(
            "No pending approval prompt. Use /deny only when EdgeCrab is explicitly waiting for approval or clarification.",
            OutputRole::System,
        );
    }

    fn configured_home_channel_platforms(
        &self,
        config: &edgecrab_core::AppConfig,
    ) -> Vec<&'static str> {
        let mut platforms = Vec::new();
        if config.gateway.platform_enabled("telegram") || config.gateway.telegram.enabled {
            platforms.push("telegram");
        }
        if config.gateway.platform_enabled("discord") || config.gateway.discord.enabled {
            platforms.push("discord");
        }
        if config.gateway.platform_enabled("slack") || config.gateway.slack.enabled {
            platforms.push("slack");
        }
        platforms
    }

    fn set_home_channel_in_config(
        &self,
        config: &mut edgecrab_core::AppConfig,
        platform: &str,
        channel: Option<String>,
    ) -> anyhow::Result<()> {
        match platform {
            "telegram" => {
                config.gateway.telegram.enabled = true;
                config.gateway.enable_platform("telegram");
                config.gateway.telegram.home_channel = channel;
            }
            "discord" => {
                config.gateway.discord.enabled = true;
                config.gateway.enable_platform("discord");
                config.gateway.discord.home_channel = channel;
            }
            "slack" => {
                config.gateway.slack.enabled = true;
                config.gateway.enable_platform("slack");
                config.gateway.slack.home_channel = channel;
            }
            _ => anyhow::bail!(
                "Unsupported platform '{platform}'. Supported platforms: telegram, discord, slack"
            ),
        }
        Ok(())
    }

    fn handle_set_home_channel(&mut self, args: String) {
        let trimmed = args.trim();
        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("status") {
            let config = self.load_runtime_config();
            self.push_output(
                self.render_gateway_home_channel_summary(&config),
                OutputRole::System,
            );
            return;
        }

        let mut parts = trimmed.split_whitespace();
        let first = parts.next().unwrap_or_default();
        let supported = ["telegram", "discord", "slack"];

        let (platform, value) = if supported.contains(&first) {
            let rest = parts.collect::<Vec<_>>().join(" ");
            if rest.is_empty() {
                self.push_output(
                    "Usage: /sethome <platform> <channel|clear>",
                    OutputRole::System,
                );
                return;
            }
            (first.to_string(), rest)
        } else {
            let config = self.load_runtime_config();
            let enabled = self.configured_home_channel_platforms(&config);
            if enabled.len() != 1 {
                self.push_output(
                    "Ambiguous home-channel target. Use: /sethome <telegram|discord|slack> <channel|clear>",
                    OutputRole::System,
                );
                return;
            }
            (enabled[0].to_string(), trimmed.to_string())
        };

        let channel = if value.eq_ignore_ascii_case("clear") {
            None
        } else {
            Some(value)
        };

        let mut config = self.load_runtime_config();
        match self.set_home_channel_in_config(&mut config, &platform, channel.clone()) {
            Ok(()) => match config.save() {
                Ok(()) => {
                    let text = match channel {
                        Some(channel) => {
                            format!("Home channel for {platform} set to: {channel}")
                        }
                        None => format!("Home channel for {platform} cleared."),
                    };
                    self.push_output(text, OutputRole::System);
                }
                Err(err) => self.push_output(
                    format!(
                        "Updated {platform} home channel in memory, but saving config failed: {err}"
                    ),
                    OutputRole::Error,
                ),
            },
            Err(err) => self.push_output(err.to_string(), OutputRole::Error),
        }
    }

    fn handle_update_status(&mut self) {
        let tx = self.response_tx.clone();
        let config = self.load_runtime_config();
        self.display_state = DisplayState::BgOp {
            label: "Checking update status…".into(),
            frame: 0,
            started: Instant::now(),
        };
        self.needs_redraw = true;
        self.rt_handle.spawn(async move {
            let report = match crate::update::resolve_update_status(&config, true).await {
                Ok(status) => crate::update::render_update_report(&status),
                Err(err) => format!("Update check failed: {err}"),
            };
            let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::SystemMsg(report)));
        });
    }

    fn handle_gateway_control(&mut self, args: String) {
        let action = match args.trim().to_ascii_lowercase().as_str() {
            "" | "status" => crate::gateway_cmd::GatewayAction::Status,
            "start" => crate::gateway_cmd::GatewayAction::Start { foreground: false },
            "stop" => crate::gateway_cmd::GatewayAction::Stop,
            "restart" => crate::gateway_cmd::GatewayAction::Restart,
            "diagnose" | "diag" => crate::gateway_cmd::GatewayAction::Diagnose,
            other => {
                self.push_output(
                    format!(
                        "Unknown gateway action '{other}'. Use: /gateway [start|stop|restart|status|diagnose]"
                    ),
                    OutputRole::System,
                );
                return;
            }
        };

        let is_diagnose = matches!(action, crate::gateway_cmd::GatewayAction::Diagnose);

        let label = match action {
            crate::gateway_cmd::GatewayAction::Start { .. } => "Starting gateway…",
            crate::gateway_cmd::GatewayAction::Stop => "Stopping gateway…",
            crate::gateway_cmd::GatewayAction::Restart => "Restarting gateway…",
            crate::gateway_cmd::GatewayAction::Status => "Inspecting gateway…",
            crate::gateway_cmd::GatewayAction::Diagnose => "Running gateway diagnostics…",
        };

        let tx = self.response_tx.clone();
        let cli_args = current_launch_cli_args();
        self.display_state = DisplayState::BgOp {
            label: label.into(),
            frame: 0,
            started: Instant::now(),
        };
        // Mark refresh in-flight so the overlay can show a spinner if open.
        if is_diagnose {
            self.diagnose_panel.refresh_in_flight = true;
        }
        self.needs_redraw = true;
        self.rt_handle.spawn(async move {
            let result = crate::gateway_cmd::run_capture(action, &cli_args).await;
            let payload = if is_diagnose {
                match result {
                    Ok(report) => BackgroundOpResult::DiagnoseReady { report },
                    Err(err) => {
                        BackgroundOpResult::SystemMsg(format!("Gateway diagnostics failed: {err}"))
                    }
                }
            } else {
                match result {
                    Ok(report) => BackgroundOpResult::GatewayCommandDone { report },
                    Err(err) => {
                        BackgroundOpResult::SystemMsg(format!("Gateway command failed: {err}"))
                    }
                }
            };
            let _ = tx.send(AgentResponse::BgOp(payload));
        });
    }

    fn take_mouse_capture_request(&mut self) -> Option<bool> {
        self.pending_mouse_capture.take()
    }

    /// Check for agent responses from background tasks.
    pub fn check_responses(&mut self) {
        while let Ok(resp) = self.response_rx.try_recv() {
            match resp {
                AgentResponse::Token(text) => {
                    // Accumulate per-turn token count regardless of streaming mode.
                    self.turn_stream_tokens += 1;
                    // Record TTFB (Time To First Token) on the very first token that
                    // arrives out of the AwaitingFirstToken phase. This is the wall-clock
                    // latency from "submit sent" to "first model token received" — a
                    // useful calibration metric for model-selection decisions.
                    if matches!(self.display_state, DisplayState::AwaitingFirstToken { .. })
                        && self.last_ttfb_secs.is_none()
                        && let DisplayState::AwaitingFirstToken { ref started, .. } =
                            self.display_state
                    {
                        self.last_ttfb_secs = Some(started.elapsed().as_secs_f32());
                    }
                    // Transition to streaming state on first token of a new phase.
                    if self.streaming_enabled
                        && matches!(
                            self.display_state,
                            DisplayState::AwaitingFirstToken { .. }
                                | DisplayState::Thinking { .. }
                                | DisplayState::ToolExec { .. }
                        )
                    {
                        // WHY turn_stream_tokens: initialise from the running total so
                        // the status bar shows cumulative tokens even after tool-call
                        // interruptions, rather than resetting to 0 each streaming phase.
                        self.display_state = DisplayState::Streaming {
                            token_count: self.turn_stream_tokens,
                            chars_written: 0,
                            current_section: None,
                            started: Instant::now(),
                        };
                    }
                    // Keep the Streaming state's token_count, chars_written, and current_section
                    // in sync as new tokens arrive.
                    let new_chars = text.len() as u64;
                    if let DisplayState::Streaming {
                        ref mut token_count,
                        ref mut chars_written,
                        ref mut current_section,
                        ..
                    } = self.display_state
                    {
                        *token_count = self.turn_stream_tokens;
                        *chars_written += new_chars;
                        // Detect new markdown headings in the accumulated streaming text.
                        // We check for `\n# ` and `\n## ` patterns in the current token
                        // plus a small look-behind (the text ends with `\n#...`).
                        extract_streaming_section(&text, current_section);
                    }

                    if self.live_token_display_enabled {
                        if let Some(idx) = self.streaming_line {
                            if idx < self.output.len() {
                                self.output[idx].text.push_str(&text);
                                self.output[idx].invalidate_render_cache();
                            }
                        } else {
                            self.output.push(OutputLine {
                                text: text.clone(),
                                role: OutputRole::Assistant,
                                prebuilt_spans: None,
                                rendered: None,
                                plain_rendered: None,
                            });
                            self.streaming_line = Some(self.output.len() - 1);
                            // Only auto-scroll to bottom if the user is already there
                            if self.at_bottom {
                                self.scroll_offset = 0;
                            }
                        }
                        self.needs_redraw = true;
                    } else {
                        self.buffered_assistant_output.push_str(&text);
                    }
                    // Accumulate response text for voice mode TTS readback.
                    self.last_agent_response_text.push_str(&text);
                }
                AgentResponse::Notice(text) => {
                    // Detect steering-applied notices to update steer state.
                    if text.starts_with("⛵ Steering applied") {
                        self.steer_applied_at = Some(Instant::now());
                        self.pending_steer_count = self.pending_steer_count.saturating_sub(1);
                    }
                    self.push_output(text, OutputRole::System);
                    self.needs_redraw = true;
                }
                AgentResponse::DirectToolOutput(text) => {
                    self.push_output(text, OutputRole::System);
                    self.needs_redraw = true;
                }
                AgentResponse::Reasoning(text) => {
                    if matches!(self.display_state, DisplayState::AwaitingFirstToken { .. }) {
                        self.display_state = DisplayState::Thinking {
                            frame: 0,
                            started: Instant::now(),
                        };
                    }
                    if self.show_reasoning && !text.trim().is_empty() {
                        if let Some(idx) = self.reasoning_line {
                            if idx < self.output.len() {
                                self.output[idx].text.push_str(&text);
                                self.output[idx].invalidate_render_cache();
                            }
                        } else {
                            let line = OutputLine {
                                text: format!("Thinking\n{text}"),
                                role: OutputRole::Reasoning,
                                prebuilt_spans: None,
                                rendered: None,
                                plain_rendered: None,
                            };
                            if let Some(idx) = self.streaming_line {
                                let insert_idx = idx.min(self.output.len());
                                self.output.insert(insert_idx, line);
                                self.reasoning_line = Some(insert_idx);
                                self.streaming_line = Some(insert_idx + 1);
                            } else {
                                self.output.push(line);
                                self.reasoning_line = Some(self.output.len() - 1);
                            }
                            if self.at_bottom {
                                self.scroll_offset = 0;
                            }
                        }
                        self.needs_redraw = true;
                    }
                }
                AgentResponse::ToolExec {
                    tool_call_id,
                    name,
                    args_json,
                } => {
                    self.flush_buffered_assistant_output();
                    // CRITICAL: Break the streaming buffer at the tool boundary.
                    // Without this, tokens arriving after the tool call append to
                    // the pre-tool text, visually merging text before and after
                    // the tool call into a single garbled line.
                    self.streaming_line = None;
                    // Track parallel in-flight tools — multiple ToolExec events
                    // may arrive before any ToolDone (parallel tool dispatch).
                    self.in_flight_tool_count = self.in_flight_tool_count.saturating_add(1);
                    self.progress_seq = self.progress_seq.saturating_add(1);
                    let started_at = Instant::now();
                    self.active_tools.insert(
                        tool_call_id.clone(),
                        ActiveToolStatus {
                            name: name.clone(),
                            args_json: args_json.clone(),
                            last_detail: None,
                            started_at,
                            last_seq: self.progress_seq,
                        },
                    );
                    self.display_state = DisplayState::ToolExec {
                        tool_call_id: tool_call_id.clone(),
                        name: name.clone(),
                        args_json: args_json.clone(),
                        detail: None,
                        frame: 0,
                        started: started_at,
                    };
                    if self.should_render_tool_call(&name, &args_json) {
                        // Push a live "in-flight" placeholder line to the output area.
                        //
                        // WHY immediately: Long tool operations (web fetch, terminal,
                        // delegate) can take 10-60 s.  Without this placeholder the
                        // output area appears frozen — only the status-bar spinner
                        // moves.  The placeholder gives the user a place in the
                        // scrollable transcript to see that work is happening, and
                        // ToolDone later upgrades it in-place with timing/result
                        // info (no layout shift).
                        let edit_snapshot = capture_local_edit_snapshot(&name, &args_json);
                        let running_spans = build_tool_running_line_width(
                            &name,
                            &args_json,
                            None,
                            &self.theme.tool_emojis,
                            &DisplayWidths::from_terminal_width(self.last_terminal_width as usize),
                        );
                        let line_idx = self.output.len();
                        self.output.push(OutputLine {
                            text: String::new(),
                            role: OutputRole::Tool,
                            prebuilt_spans: Some(running_spans),
                            rendered: None,
                            plain_rendered: None,
                        });
                        self.pending_tool_lines.insert(
                            tool_call_id,
                            PendingToolLine {
                                tool_name: name,
                                args_json,
                                line_idx,
                                edit_snapshot,
                            },
                        );
                    } else {
                        self.hidden_tool_calls.insert(tool_call_id);
                    }
                    if self.at_bottom {
                        self.scroll_offset = 0;
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::ToolProgress {
                    tool_call_id,
                    name,
                    message,
                } => {
                    let detail = message.trim().to_string();
                    if detail.is_empty() {
                        continue;
                    }
                    self.progress_seq = self.progress_seq.saturating_add(1);
                    if let Some(status) = self.active_tools.get_mut(&tool_call_id) {
                        status.last_detail = Some(detail.clone());
                        status.last_seq = self.progress_seq;
                    }
                    if let DisplayState::ToolExec {
                        tool_call_id: active_tool_call_id,
                        detail: active_detail,
                        ..
                    } = &mut self.display_state
                        && active_tool_call_id == &tool_call_id
                    {
                        *active_detail = Some(detail.clone());
                    }
                    if self.hidden_tool_calls.contains(&tool_call_id) {
                        if self.at_bottom {
                            self.scroll_offset = 0;
                        }
                        self.needs_redraw = true;
                        continue;
                    }
                    if let Some(PendingToolLine {
                        line_idx,
                        tool_name,
                        args_json,
                        ..
                    }) = self.pending_tool_lines.get(&tool_call_id).cloned()
                    {
                        if line_idx < self.output.len() {
                            let elapsed = self
                                .active_tools
                                .get(&tool_call_id)
                                .map(|s| s.started_at.elapsed().as_secs());
                            self.output[line_idx].prebuilt_spans =
                                Some(build_tool_running_line_width_elapsed(
                                    &tool_name,
                                    &args_json,
                                    Some(detail.as_str()),
                                    elapsed,
                                    &self.theme.tool_emojis,
                                    &DisplayWidths::from_terminal_width(
                                        self.last_terminal_width as usize,
                                    ),
                                ));
                            self.output[line_idx].invalidate_render_cache();
                        }
                    } else {
                        self.push_output(
                            format!("{}: {detail}", name.replace('_', " ")),
                            OutputRole::System,
                        );
                    }
                    if self.at_bottom {
                        self.scroll_offset = 0;
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::ToolDone {
                    tool_call_id,
                    name,
                    args_json,
                    result_preview,
                    duration_ms,
                    is_error,
                } => {
                    let hidden = self.hidden_tool_calls.remove(&tool_call_id);
                    // Build the final styled completion spans.
                    let pending = self.pending_tool_lines.remove(&tool_call_id);
                    self.active_tools.remove(&tool_call_id);
                    if !hidden {
                        let widths =
                            DisplayWidths::from_terminal_width(self.last_terminal_width as usize);
                        let spans = build_tool_done_line_width(
                            &name,
                            &args_json,
                            result_preview.as_deref(),
                            duration_ms,
                            is_error,
                            &self.theme.tool_emojis,
                            &widths,
                        );
                        // Upgrade the in-flight placeholder in-place (if present).
                        //
                        // WHY in-place: replacing the placeholder avoids appending a
                        // second line for the same tool call — the layout stays stable
                        // (no shift), and the cyan "···" naturally becomes the gold
                        // timing string without any visual flash.
                        if let Some(PendingToolLine { line_idx, .. }) = pending.as_ref() {
                            if *line_idx < self.output.len() {
                                self.output[*line_idx].prebuilt_spans = Some(spans);
                                self.output[*line_idx].invalidate_render_cache();
                            } else {
                                // Index out of range — fall back to append (shouldn't happen).
                                self.push_output_spans(spans, OutputRole::Tool);
                            }
                        } else {
                            // No pending placeholder (e.g. streaming disabled, or the
                            // tool fired before the feature was introduced) — append.
                            self.push_output_spans(spans, OutputRole::Tool);
                        }
                        if self.tool_progress_mode == ToolProgressMode::Verbose {
                            for line in build_tool_verbose_lines_width(
                                &name,
                                &args_json,
                                result_preview.as_deref(),
                                is_error,
                                widths.verbose_content,
                            ) {
                                self.push_output_spans(line, OutputRole::Tool);
                            }
                        }
                        if let Some(diff_lines) = render_edit_diff_lines(
                            &name,
                            &args_json,
                            is_error,
                            pending
                                .as_ref()
                                .and_then(|entry| entry.edit_snapshot.as_ref()),
                        ) {
                            for line in diff_lines {
                                self.push_output_spans(line, OutputRole::Tool);
                            }
                        }
                        if name == "report_task_status"
                            && !is_error
                            && let Some(preview) = result_preview
                                .as_deref()
                                .filter(|text| !text.trim().is_empty())
                        {
                            self.push_output(
                                format_task_status_progress_notice(preview),
                                OutputRole::System,
                            );
                        }
                    }
                    // Decrement the in-flight counter. Only transition back to
                    // Thinking when ALL parallel tools have completed; otherwise
                    // stay in ToolExec state so the status bar stays accurate.
                    self.in_flight_tool_count = self.in_flight_tool_count.saturating_sub(1);
                    if self.in_flight_tool_count == 0 {
                        self.display_state = DisplayState::AwaitingFirstToken {
                            frame: 0,
                            started: Instant::now(),
                        };
                    } else if let Some((active_tool_call_id, active)) =
                        latest_active_tool_entry(&self.active_tools)
                    {
                        self.display_state = DisplayState::ToolExec {
                            tool_call_id: active_tool_call_id.clone(),
                            name: active.name.clone(),
                            args_json: active.args_json.clone(),
                            detail: active.last_detail.clone(),
                            frame: 0,
                            started: active.started_at,
                        };
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::SubAgentStart {
                    task_index,
                    task_count,
                    goal,
                } => {
                    self.flush_buffered_assistant_output();
                    self.progress_seq = self.progress_seq.saturating_add(1);
                    self.active_subagents.insert(
                        task_index,
                        ActiveSubagentStatus {
                            task_index,
                            task_count,
                            goal: goal.clone(),
                            last_detail: None,
                            last_seq: self.progress_seq,
                        },
                    );
                    self.streaming_line = None;
                    // Push a running placeholder and record its index so that
                    // SubAgentReasoning / SubAgentToolExec can update it in-place,
                    // and SubAgentFinish can replace it — exactly like ToolExec/ToolDone.
                    let widths =
                        DisplayWidths::from_terminal_width(self.last_terminal_width as usize);
                    let running_spans = build_subagent_running_line_width(
                        task_index, task_count, &goal, None, 0, &widths,
                    );
                    let line_idx = self.output.len();
                    self.output.push(OutputLine {
                        text: String::new(),
                        role: OutputRole::Tool,
                        prebuilt_spans: Some(running_spans),
                        rendered: None,
                        plain_rendered: None,
                    });
                    self.pending_subagent_lines.insert(
                        task_index,
                        PendingSubagentLine {
                            line_idx,
                            started_at: Instant::now(),
                            goal: goal.clone(),
                            task_count,
                        },
                    );
                    if self.at_bottom {
                        self.scroll_offset = 0;
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::SubAgentReasoning {
                    task_index,
                    task_count: _task_count,
                    text,
                } => {
                    self.progress_seq = self.progress_seq.saturating_add(1);
                    let detail = format!(
                        "thinking: {}",
                        edgecrab_core::safe_truncate(text.trim(), 72)
                    );
                    if let Some(status) = self.active_subagents.get_mut(&task_index) {
                        status.last_detail = Some(detail.clone());
                        status.last_seq = self.progress_seq;
                    }
                    // Update running placeholder in-place — no new lines pushed.
                    if let Some(pending) = self.pending_subagent_lines.get(&task_index) {
                        let line_idx = pending.line_idx;
                        let goal = pending.goal.clone();
                        let task_count = pending.task_count;
                        let elapsed = pending.started_at.elapsed().as_secs();
                        if line_idx < self.output.len() {
                            let widths = DisplayWidths::from_terminal_width(
                                self.last_terminal_width as usize,
                            );
                            self.output[line_idx].prebuilt_spans =
                                Some(build_subagent_running_line_width(
                                    task_index,
                                    task_count,
                                    &goal,
                                    Some(&detail),
                                    elapsed,
                                    &widths,
                                ));
                            self.output[line_idx].invalidate_render_cache();
                        }
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::SubAgentToolExec {
                    task_index,
                    task_count: _task_count,
                    name,
                    args_json,
                } => {
                    self.progress_seq = self.progress_seq.saturating_add(1);
                    let preview = crate::tool_display::extract_tool_preview(&name, &args_json);
                    let detail = if preview.is_empty() {
                        name.clone()
                    } else {
                        format!("{name}  {preview}")
                    };
                    if let Some(status) = self.active_subagents.get_mut(&task_index) {
                        status.last_detail = Some(detail.clone());
                        status.last_seq = self.progress_seq;
                    }
                    // Update the running placeholder in-place — do NOT push a new line.
                    // Showing every sub-agent tool call as a permanent line creates
                    // O(tool_calls × subagents) output noise.
                    if let Some(pending) = self.pending_subagent_lines.get(&task_index) {
                        let line_idx = pending.line_idx;
                        let goal = pending.goal.clone();
                        let task_count = pending.task_count;
                        let elapsed = pending.started_at.elapsed().as_secs();
                        if line_idx < self.output.len() {
                            let widths = DisplayWidths::from_terminal_width(
                                self.last_terminal_width as usize,
                            );
                            self.output[line_idx].prebuilt_spans =
                                Some(build_subagent_running_line_width(
                                    task_index,
                                    task_count,
                                    &goal,
                                    Some(&detail),
                                    elapsed,
                                    &widths,
                                ));
                            self.output[line_idx].invalidate_render_cache();
                        }
                    }
                    if self.at_bottom {
                        self.scroll_offset = 0;
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::SubAgentFinish {
                    task_index,
                    task_count,
                    status,
                    duration_ms,
                    summary,
                    api_calls,
                    model,
                } => {
                    self.active_subagents.remove(&task_index);
                    self.streaming_line = None;
                    let is_error = status != "completed";
                    let widths =
                        DisplayWidths::from_terminal_width(self.last_terminal_width as usize);
                    let done_spans = build_subagent_done_line_width(
                        task_index,
                        task_count,
                        is_error,
                        duration_ms,
                        api_calls,
                        model.as_deref().filter(|m| !m.is_empty()),
                        summary.trim(),
                        &widths,
                    );
                    // Replace the running placeholder in-place; fall back to append when
                    // the placeholder is missing (e.g. session restore, race condition).
                    if let Some(pending) = self.pending_subagent_lines.remove(&task_index) {
                        if pending.line_idx < self.output.len() {
                            self.output[pending.line_idx].prebuilt_spans = Some(done_spans);
                            self.output[pending.line_idx].invalidate_render_cache();
                        } else {
                            self.push_output_spans(done_spans, OutputRole::Tool);
                        }
                    } else {
                        self.push_output_spans(done_spans, OutputRole::Tool);
                    }
                    if self.at_bottom {
                        self.scroll_offset = 0;
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::RunFinished { outcome } => {
                    self.flush_buffered_assistant_output();
                    let outcome = self.maybe_apply_stop_hooks(outcome);
                    self.last_run_outcome = Some(outcome.clone());
                    self.push_output(
                        format_run_outcome_notice(&outcome),
                        run_outcome_role(&outcome),
                    );
                    self.needs_redraw = true;
                }
                AgentResponse::Done => {
                    self.flush_buffered_assistant_output();
                    self.clear_active_request_state();
                    self.last_response_time = Some(Instant::now());
                    self.turn_count += 1;
                    self.needs_redraw = true;

                    // Show TTFB calibration hint when the wait was noticeable (>1s).
                    // This surfaces the model's latency characteristics to the user
                    // without requiring any external tooling.
                    if let Some(ttfb) = self.last_ttfb_secs.take()
                        && ttfb >= 1.0
                    {
                        self.push_output(
                            format!("  \u{21b3} ttfb: {ttfb:.1}s"),
                            OutputRole::System,
                        );
                    }

                    // Auto-update status bar tokens/cost from agent
                    self.auto_update_status();

                    // Voice mode: speak the response via direct TTS after each
                    // turn. This avoids routing a deterministic action back
                    // through the model and removes a major source of flakiness.
                    let response_text = std::mem::take(&mut self.last_agent_response_text);
                    if self.voice_mode_enabled && !response_text.is_empty() {
                        self.spawn_direct_tts(response_text, false);
                    }
                    if self.voice_continuous_active && !self.voice_playback_active {
                        self.maybe_restart_continuous_voice_session(
                            "Response finished. Listening again for continuous voice...",
                        );
                    }

                    if let Some(next) = self.prompt_queue.first().cloned() {
                        self.prompt_queue.remove(0);
                        self.process_input(&next);
                    }
                }
                AgentResponse::Error(err) => {
                    self.flush_buffered_assistant_output();
                    self.clear_active_request_state();
                    self.push_output(format!("⚠ Run failed\n{}", err.trim()), OutputRole::Error);
                    if self.voice_continuous_active {
                        self.stop_continuous_voice_session(false);
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::Clarify {
                    question,
                    choices,
                    response_tx,
                } => {
                    // Display the question prominently and wait for the user.
                    // The agent is paused — it will resume once the oneshot sender
                    // is fulfilled. We store the sender and route the user's next
                    // Enter key press to it instead of treating it as a new prompt.
                    self.display_state = DisplayState::WaitingForClarify;
                    self.push_output(format!("❓ {question}"), OutputRole::System);
                    // Render predefined choices as a numbered list so the user can
                    // type a number or their own answer. A 5th "Other" option is
                    // implied; the user may also type free-form text.
                    if let Some(ref list) = choices {
                        for (i, choice) in list.iter().enumerate() {
                            self.push_output(
                                format!("  {}. {}", i + 1, choice),
                                OutputRole::System,
                            );
                        }
                        self.push_output(
                            format!("  {}. Other (type your answer)", list.len() + 1),
                            OutputRole::System,
                        );
                    }
                    self.clarify_pending_tx = Some(response_tx);
                    self.textarea.set_block(
                        Block::default()
                            .borders(Borders::ALL)
                            .border_style(Style::default().fg(Color::Rgb(255, 220, 80)))
                            .title(" ❓ Reply: "),
                    );
                    self.needs_redraw = true;
                }
                AgentResponse::Approval {
                    command,
                    full_command,
                    response_tx,
                } => {
                    // Check the session-level approval cache first.
                    // SHA-256 key is the exact full_command string so permission is
                    // tight — "rm -rf /tmp/a" and "rm -rf /tmp/b" are distinct keys.
                    use std::hash::{Hash, Hasher};
                    let mut h = std::collections::hash_map::DefaultHasher::new();
                    full_command.hash(&mut h);
                    let cache_key = format!("{:x}", h.finish());

                    if self.session_approvals.contains(&cache_key) {
                        // Already approved for this session — auto-accept.
                        let _ = response_tx.send(edgecrab_core::ApprovalChoice::Once);
                        self.needs_redraw = true;
                    } else {
                        // Surface the approval overlay.
                        self.display_state = DisplayState::WaitingForApproval {
                            command: command.clone(),
                            full_command,
                            selected: 0,
                            show_full: false,
                            scroll_offset: 0,
                        };
                        self.approval_pending_tx = Some(response_tx);
                        self.needs_redraw = true;
                    }
                }
                AgentResponse::SecretRequest {
                    var_name,
                    prompt,
                    is_sudo,
                    response_tx,
                } => {
                    // Surface the masked-input overlay.
                    self.display_state = DisplayState::SecretCapture {
                        var_name,
                        prompt,
                        is_sudo,
                        buffer: String::new(),
                    };
                    self.secret_pending_tx = Some(response_tx);
                    self.needs_redraw = true;
                }
                AgentResponse::BgOp(result) => {
                    if matches!(self.display_state, DisplayState::BgOp { .. }) {
                        self.display_state = DisplayState::Idle;
                        self.needs_redraw = true;
                    }
                    match result {
                        BackgroundOpResult::ModelCatalogReady {
                            models,
                            current_model,
                        } => {
                            self.model_selector_refresh_in_flight = false;
                            self.apply_model_selector_catalog(
                                models,
                                &current_model,
                                true,
                                self.model_selector_target,
                            );
                        }
                        BackgroundOpResult::SystemMsg(text) => {
                            self.push_output(text, OutputRole::System);
                        }
                        BackgroundOpResult::GatewayCommandDone { report } => {
                            self.push_output(report, OutputRole::System);
                            self.refresh_gateway_browser();
                        }
                        BackgroundOpResult::DiagnoseReady { report } => {
                            // Count lines before storing for scroll-clamping.
                            let total = report.lines().count();
                            self.diagnose_panel.report = report;
                            self.diagnose_panel.total_lines = total;
                            self.diagnose_panel.scroll = 0;
                            self.diagnose_panel.active = true;
                            self.diagnose_panel.refresh_in_flight = false;
                            self.needs_redraw = true;
                        }
                        BackgroundOpResult::ModelSwitchDone { model } => {
                            self.model_name = model.clone();
                            self.update_context_window();
                            match persist_model_to_config(&model) {
                                Ok(()) => self.push_output(
                                    format!("Model switched to: {model} (saved as default for next run)"),
                                    OutputRole::System,
                                ),
                                Err(e) => self.push_output(
                                    format!("Model switched to: {model} (warning: failed to save default: {e})"),
                                    OutputRole::System,
                                ),
                            }
                        }
                        BackgroundOpResult::CompressDone { msg } => {
                            self.push_output(msg, OutputRole::System);
                        }
                    }
                }
                AgentResponse::RemoteSkillSearchReady {
                    request_id,
                    query,
                    report,
                } => {
                    self.apply_remote_skill_search_result(request_id, query, report);
                }
                AgentResponse::RemotePluginSearchReady {
                    request_id,
                    query,
                    report,
                } => {
                    self.apply_remote_plugin_search_result(request_id, query, report);
                }
                AgentResponse::RemoteMcpSearchReady {
                    request_id,
                    query,
                    report,
                } => {
                    self.apply_remote_mcp_search_result(request_id, query, report);
                }
                AgentResponse::RemotePluginActionComplete {
                    message,
                    plugin_name,
                } => {
                    self.remote_plugin_browser.action_in_flight = None;
                    if let Err(error) = self.refresh_agent_plugin_runtime() {
                        self.push_output(
                            format!("Plugin runtime refresh failed: {error}"),
                            OutputRole::Error,
                        );
                    }
                    self.push_output(
                        format!("{message}\nInspect with: /plugins info {plugin_name}"),
                        OutputRole::System,
                    );
                    if self.remote_plugin_browser.selector.active {
                        self.schedule_remote_plugin_search(true);
                    }
                }
                AgentResponse::RemotePluginActionFailed {
                    action_label,
                    identifier,
                    error,
                } => {
                    self.remote_plugin_browser.action_in_flight = None;
                    self.push_output(
                        format!("Remote {action_label} failed for '{identifier}': {error}"),
                        OutputRole::Error,
                    );
                    self.needs_redraw = true;
                }
                AgentResponse::RemoteSkillActionComplete {
                    message,
                    skill_name,
                } => {
                    self.remote_skill_browser.action_in_flight = None;
                    self.refresh_skills_list();
                    self.push_output(
                        format!("{message}\nActivate with: /skills view {skill_name}"),
                        OutputRole::System,
                    );
                    if self.remote_skill_browser.selector.active {
                        self.schedule_remote_skill_search(true);
                    }
                }
                AgentResponse::RemoteSkillActionFailed {
                    action_label,
                    identifier,
                    error,
                } => {
                    self.remote_skill_browser.action_in_flight = None;
                    self.push_output(
                        format!("Remote {action_label} failed for '{identifier}': {error}"),
                        OutputRole::Error,
                    );
                    self.needs_redraw = true;
                }
                AgentResponse::VoiceTranscript {
                    transcript,
                    submit_to_agent,
                    meta,
                } => {
                    let transcript = normalize_voice_transcript(&transcript);
                    let filtered = meta.is_some_and(|meta| {
                        self.voice_hallucination_filter
                            && is_probable_voice_hallucination(
                                &transcript,
                                meta.capture_duration_secs,
                            )
                    });
                    if transcript.trim().is_empty() {
                        self.push_output(
                            "Transcription completed, but no speech was detected.",
                            OutputRole::System,
                        );
                        self.note_empty_voice_capture();
                    } else if filtered {
                        self.push_output(
                            format!(
                                "Filtered probable STT hallucination from a short capture:\n{}",
                                transcript
                            ),
                            OutputRole::System,
                        );
                        self.note_empty_voice_capture();
                    } else if submit_to_agent {
                        self.voice_no_speech_count = 0;
                        self.push_output(
                            format!("Voice reply transcript:\n{transcript}"),
                            OutputRole::System,
                        );
                        self.process_input(&transcript);
                    } else {
                        self.voice_no_speech_count = 0;
                        self.push_output(
                            format!("Voice transcript:\n{transcript}"),
                            OutputRole::System,
                        );
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::VoiceCaptureFailed {
                    error,
                    continuous_session,
                } => {
                    if continuous_session {
                        self.voice_continuous_active = false;
                        self.voice_no_speech_count = 0;
                        self.push_output(
                            format!("{error}\nContinuous voice stopped to avoid a restart loop."),
                            OutputRole::Error,
                        );
                    } else {
                        self.push_output(error, OutputRole::Error);
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::VoicePlaybackFinished => {
                    self.voice_playback_active = false;
                    if self.voice_continuous_active && !self.is_processing {
                        self.maybe_restart_continuous_voice_session(
                            "Spoken reply finished. Listening again for continuous voice...",
                        );
                    }
                    self.needs_redraw = true;
                }
                AgentResponse::BackgroundPromptComplete {
                    task_num,
                    task_id,
                    prompt_preview,
                    response,
                } => {
                    self.background_tasks_active.remove(&task_id);
                    let body = if response.trim().is_empty() {
                        "(No response generated)".to_string()
                    } else {
                        response
                    };
                    self.push_output(
                        format!(
                            "EdgeCrab (background #{task_num})\nTask ID: {task_id}\nPrompt: \"{prompt_preview}\"\n\n{body}"
                        ),
                        OutputRole::Assistant,
                    );
                }
                AgentResponse::BackgroundPromptProgress { task_id, text, .. } => {
                    if let Some(status) = self.background_tasks_active.get_mut(&task_id) {
                        self.progress_seq = self.progress_seq.saturating_add(1);
                        status.last_progress = Some(text.clone());
                        status.last_seq = self.progress_seq;
                        self.push_output(text, OutputRole::System);
                    }
                }
                AgentResponse::BackgroundPromptFailed {
                    task_num,
                    task_id,
                    error,
                } => {
                    self.background_tasks_active.remove(&task_id);
                    self.push_output(
                        format!(
                            "Background task #{task_num} failed\nTask ID: {task_id}\nError: {error}"
                        ),
                        OutputRole::Error,
                    );
                }
                AgentResponse::SideQuestionComplete { question, response } => {
                    let body = if response.trim().is_empty() {
                        "(No response generated)".to_string()
                    } else {
                        response
                    };
                    self.push_output(
                        format!(
                            "/btw {}\n\n{body}",
                            edgecrab_core::safe_truncate(question.trim(), 72)
                        ),
                        OutputRole::Assistant,
                    );
                }
                AgentResponse::SideQuestionFailed { question, error } => {
                    self.push_output(
                        format!(
                            "/btw {}\nError: {error}",
                            edgecrab_core::safe_truncate(question.trim(), 72)
                        ),
                        OutputRole::Error,
                    );
                }
            }
        }

        // Drain cron job completion notifications from the background scheduler.
        // These arrive as pre-formatted markdown strings and are shown as
        // assistant-style output so the user knows a job completed.
        while let Ok(msg) = self.cron_rx.try_recv() {
            self.push_output(msg, OutputRole::Assistant);
            self.needs_redraw = true;
        }
    }

    /// Auto-update status bar after each agent response.
    fn auto_update_status(&mut self) {
        if let Some(agent) = self.agent.clone() {
            let snap = self
                .rt_handle
                .block_on(async { agent.session_snapshot().await });
            self.total_tokens = snap.context_pressure_tokens();
            self.model_name = snap.model;
            self.update_context_window();

            let usage = edgecrab_core::CanonicalUsage {
                input_tokens: snap.input_tokens,
                output_tokens: snap.output_tokens,
                cache_read_tokens: snap.cache_read_tokens,
                cache_write_tokens: snap.cache_write_tokens,
                reasoning_tokens: snap.reasoning_tokens,
            };
            let cost_result = edgecrab_core::estimate_cost(&usage, &self.model_name);
            if let Some(usd) = cost_result.amount_usd {
                self.session_cost = usd;
            }
        }
    }

    /// Handle a key event when the approval overlay is active.
    ///
    /// Choice order: [Once, Session, Always, Deny] (indices 0–3).
    /// ← / → navigate; Enter confirms; 'v' toggles full-command view; Esc = Deny.
    fn handle_approval_key(&mut self, key: crossterm::event::KeyEvent) {
        const CHOICES: usize = 4; // Once / Session / Always / Deny

        // Extract mutable fields we need while avoiding the borrow-checker
        let (selected, show_full) = if let DisplayState::WaitingForApproval {
            ref mut selected,
            ref mut show_full,
            ..
        } = self.display_state
        {
            (*selected, *show_full)
        } else {
            return;
        };

        match key.code {
            KeyCode::Left | KeyCode::Char('h') => {
                if let DisplayState::WaitingForApproval {
                    ref mut selected, ..
                } = self.display_state
                    && *selected > 0
                {
                    *selected -= 1;
                }
            }
            KeyCode::Right | KeyCode::Char('l') => {
                if let DisplayState::WaitingForApproval {
                    ref mut selected, ..
                } = self.display_state
                    && *selected + 1 < CHOICES
                {
                    *selected += 1;
                }
            }
            KeyCode::Char('v') => {
                if let DisplayState::WaitingForApproval {
                    ref mut show_full, ..
                } = self.display_state
                {
                    *show_full = !*show_full;
                }
            }
            KeyCode::Up | KeyCode::Char('k') => {
                if let DisplayState::WaitingForApproval {
                    ref mut scroll_offset,
                    ..
                } = self.display_state
                {
                    *scroll_offset = scroll_offset.saturating_add(1);
                }
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let DisplayState::WaitingForApproval {
                    ref mut scroll_offset,
                    ..
                } = self.display_state
                {
                    *scroll_offset = scroll_offset.saturating_sub(1);
                }
            }
            KeyCode::Enter => {
                let choice = match selected {
                    0 => edgecrab_core::ApprovalChoice::Once,
                    1 => edgecrab_core::ApprovalChoice::Session,
                    2 => edgecrab_core::ApprovalChoice::Always,
                    _ => edgecrab_core::ApprovalChoice::Deny,
                };
                self.apply_approval_choice(choice);
            }
            KeyCode::Esc => {
                self.apply_approval_choice(edgecrab_core::ApprovalChoice::Deny);
            }
            _ => {}
        }

        let _ = (selected, show_full); // suppress unused warnings
        self.needs_redraw = true;
    }

    /// Handle a key press when the secret-capture overlay is active.
    ///
    /// - Printable characters are appended to the buffer.
    /// - Backspace deletes the last character.
    /// - Enter sends the buffer to the agent and returns to `Idle`.
    /// - Esc sends an empty string (abort) and returns to `Idle`.
    fn handle_secret_capture_key(&mut self, key: crossterm::event::KeyEvent) {
        match key.code {
            KeyCode::Char(c)
                if !key
                    .modifiers
                    .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
            {
                if let DisplayState::SecretCapture { ref mut buffer, .. } = self.display_state {
                    buffer.push(c);
                }
            }
            KeyCode::Backspace => {
                if let DisplayState::SecretCapture { ref mut buffer, .. } = self.display_state {
                    buffer.pop();
                }
            }
            KeyCode::Enter => {
                let secret = if let DisplayState::SecretCapture { ref mut buffer, .. } =
                    self.display_state
                {
                    let s = buffer.clone();
                    // Zero the buffer immediately for security.
                    buffer.clear();
                    s
                } else {
                    String::new()
                };
                if let Some(tx) = self.secret_pending_tx.take() {
                    let _ = tx.send(secret);
                }
                self.display_state = DisplayState::AwaitingFirstToken {
                    frame: 0,
                    started: std::time::Instant::now(),
                };
            }
            KeyCode::Esc => {
                // Esc = abort (send empty string)
                if let DisplayState::SecretCapture { ref mut buffer, .. } = self.display_state {
                    buffer.clear();
                }
                if let Some(tx) = self.secret_pending_tx.take() {
                    let _ = tx.send(String::new());
                }
                self.display_state = DisplayState::Idle;
            }
            _ => {}
        }
        self.needs_redraw = true;
    }

    fn handle_value_capture_key(&mut self, key: crossterm::event::KeyEvent) {
        match key.code {
            KeyCode::Char(c)
                if !key
                    .modifiers
                    .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
            {
                if let DisplayState::ValueCapture { ref mut buffer, .. } = self.display_state {
                    buffer.push(c);
                }
            }
            KeyCode::Backspace => {
                if let DisplayState::ValueCapture { ref mut buffer, .. } = self.display_state {
                    buffer.pop();
                }
            }
            KeyCode::Enter => {
                let (action, value) = match &mut self.display_state {
                    DisplayState::ValueCapture { action, buffer, .. } => {
                        (action.clone(), buffer.trim().to_string())
                    }
                    _ => return,
                };
                self.display_state = DisplayState::Idle;
                self.apply_value_capture_action(action, value);
            }
            KeyCode::Esc => {
                self.display_state = DisplayState::Idle;
                self.needs_redraw = true;
            }
            _ => {}
        }
        self.needs_redraw = true;
    }

    fn apply_value_capture_action(&mut self, action: ValueCaptureAction, value: String) {
        let refresh_gateway = matches!(
            &action,
            ValueCaptureAction::BindAddress
                | ValueCaptureAction::HomeChannel(_)
                | ValueCaptureAction::AllowedUsers(_)
                | ValueCaptureAction::PrimaryField(_)
        );

        match action {
            ValueCaptureAction::BindAddress => {
                let trimmed = value.trim();
                let bind = if trimmed.eq_ignore_ascii_case("clear") || trimmed.is_empty() {
                    "127.0.0.1:8080".to_string()
                } else {
                    trimmed.to_string()
                };
                let Some((host, port)) = bind.rsplit_once(':') else {
                    self.push_output(
                        "Bind must use host:port format, for example 127.0.0.1:8080",
                        OutputRole::Error,
                    );
                    return;
                };
                let Ok(port) = port.parse::<u16>() else {
                    self.push_output("Gateway port must be a valid TCP port.", OutputRole::Error);
                    return;
                };
                if port == 0 {
                    self.push_output(
                        "Gateway port must be between 1 and 65535.",
                        OutputRole::Error,
                    );
                    return;
                }
                let mut config = self.load_runtime_config();
                config.gateway.host = host.trim().to_string();
                config.gateway.port = port;
                match config.save() {
                    Ok(()) => self.push_output(
                        format!(
                            "Gateway bind set to {}:{}",
                            config.gateway.host, config.gateway.port
                        ),
                        OutputRole::System,
                    ),
                    Err(error) => self.push_output(
                        format!("Failed to save gateway bind: {error}"),
                        OutputRole::Error,
                    ),
                }
            }
            ValueCaptureAction::HomeChannel(platform) => {
                let mut config = self.load_runtime_config();
                let channel = if value.is_empty() || value.eq_ignore_ascii_case("clear") {
                    None
                } else {
                    Some(value)
                };
                match self.set_home_channel_in_config(&mut config, &platform, channel.clone()) {
                    Ok(()) => match config.save() {
                        Ok(()) => self.push_output(
                            match channel {
                                Some(channel) => {
                                    format!("Home channel for {platform} set to: {channel}")
                                }
                                None => format!("Home channel for {platform} cleared."),
                            },
                            OutputRole::System,
                        ),
                        Err(error) => self.push_output(
                            format!("Failed to save {platform} home channel: {error}"),
                            OutputRole::Error,
                        ),
                    },
                    Err(error) => self.push_output(error.to_string(), OutputRole::Error),
                }
            }
            ValueCaptureAction::AllowedUsers(platform) => {
                let values = if value.is_empty() || value.eq_ignore_ascii_case("clear") {
                    Vec::new()
                } else {
                    value
                        .split(',')
                        .map(str::trim)
                        .filter(|entry| !entry.is_empty())
                        .map(ToString::to_string)
                        .collect::<Vec<_>>()
                };
                let mut config = self.load_runtime_config();
                let env_key = format!("{}_ALLOWED_USERS", platform.to_ascii_uppercase());
                let save_result: Result<(), String> = match platform.as_str() {
                    "telegram" => {
                        config.gateway.telegram.allowed_users = values.clone();
                        config.save().map_err(|error| error.to_string())
                    }
                    "discord" => {
                        config.gateway.discord.allowed_users = values.clone();
                        config.save().map_err(|error| error.to_string())
                    }
                    "slack" => {
                        config.gateway.slack.allowed_users = values.clone();
                        config.save().map_err(|error| error.to_string())
                    }
                    "signal" => {
                        config.gateway.signal.allowed_users = values.clone();
                        config.save().map_err(|error| error.to_string())
                    }
                    "whatsapp" => {
                        config.gateway.whatsapp.allowed_users = values.clone();
                        config.save().map_err(|error| error.to_string())
                    }
                    _ => if values.is_empty() {
                        crate::gateway_setup::remove_env_key(&env_key)
                    } else {
                        crate::gateway_setup::save_env_key(&env_key, &values.join(","))
                    }
                    .map_err(|error| error.to_string()),
                };
                match save_result {
                    Ok(()) => self.push_output(
                        if values.is_empty() {
                            format!("{platform} allowlist cleared.")
                        } else {
                            format!("{platform} allowlist updated ({} entrie(s)).", values.len())
                        },
                        OutputRole::System,
                    ),
                    Err(error) => self.push_output(
                        format!("Failed to save {platform} allowlist: {error}"),
                        OutputRole::Error,
                    ),
                }
            }
            ValueCaptureAction::PrimaryField(field) => {
                if !self.save_gateway_primary_field(&field, &value) {
                    return;
                }
            }
            ValueCaptureAction::ProfileCreate => {
                let tokens = match shell_words::split(&value) {
                    Ok(tokens) => tokens,
                    Err(err) => {
                        self.push_output(format!("profile create: {err}"), OutputRole::Error);
                        return;
                    }
                };
                let Some(name) = tokens.first() else {
                    self.push_output(
                        "profile create: enter a name, for example `research --clone-from work`.",
                        OutputRole::Error,
                    );
                    return;
                };
                let clone = tokens.iter().any(|token| token == "--clone");
                let clone_all = tokens.iter().any(|token| token == "--clone-all");
                let clone_from = tokens
                    .windows(2)
                    .find_map(|window| (window[0] == "--clone-from").then_some(window[1].as_str()));
                self.execute_profile_create(name, clone, clone_all, clone_from);
            }
            ValueCaptureAction::ProfileRename(old_name) => {
                if value.is_empty() {
                    self.push_output(
                        "profile rename: enter the new profile name.",
                        OutputRole::Error,
                    );
                    return;
                }
                self.execute_profile_rename(&old_name, &value);
            }
            ValueCaptureAction::ProfileDeleteConfirm(name) => {
                if value != name {
                    self.push_output(
                        format!("profile delete: type `{name}` exactly to confirm."),
                        OutputRole::Error,
                    );
                    return;
                }
                self.execute_profile_delete(&name);
            }
            ValueCaptureAction::ProfileAlias(name) => {
                if value.eq_ignore_ascii_case("clear") || value.is_empty() {
                    self.execute_profile_alias(&name, true, None);
                } else {
                    self.execute_profile_alias(&name, false, Some(value.as_str()));
                }
            }
            ValueCaptureAction::ProfileExport(name) => {
                let output = (!value.is_empty()).then_some(value.as_str());
                self.execute_profile_export(&name, output);
            }
            ValueCaptureAction::ProfileImport => {
                let tokens = match shell_words::split(&value) {
                    Ok(tokens) => tokens,
                    Err(err) => {
                        self.push_output(format!("profile import: {err}"), OutputRole::Error);
                        return;
                    }
                };
                let Some(archive) = tokens.first() else {
                    self.push_output(
                        "profile import: enter an archive path and optional target name.",
                        OutputRole::Error,
                    );
                    return;
                };
                self.execute_profile_import(archive, tokens.get(1).map(String::as_str));
            }
        }
        if refresh_gateway {
            self.refresh_gateway_browser();
        }
    }

    fn save_gateway_primary_field(&mut self, field: &str, value: &str) -> bool {
        let clear = value.is_empty() || value.eq_ignore_ascii_case("clear");
        let result: Result<(), String> = match field {
            "SIGNAL_HTTP_URL" => {
                let mut config = self.load_runtime_config();
                config.gateway.signal.http_url = (!clear).then_some(value.to_string());
                config.save().map_err(|error| error.to_string())
            }
            "SIGNAL_ACCOUNT" => {
                let mut config = self.load_runtime_config();
                config.gateway.signal.account = (!clear).then_some(value.to_string());
                config.save().map_err(|error| error.to_string())
            }
            "WHATSAPP_MODE" => {
                let normalized = if clear {
                    "self-chat".to_string()
                } else {
                    value.trim().to_ascii_lowercase()
                };
                if normalized != "bot" && normalized != "self-chat" {
                    self.push_output(
                        "WhatsApp mode must be `bot` or `self-chat`.",
                        OutputRole::Error,
                    );
                    return false;
                }
                let mut config = self.load_runtime_config();
                config.gateway.whatsapp.mode = normalized.clone();
                match config.save() {
                    Ok(()) => {
                        self.push_output(
                            format!("WhatsApp mode set to: {}", config.gateway.whatsapp.mode),
                            OutputRole::System,
                        );
                        return true;
                    }
                    Err(error) => {
                        self.push_output(
                            format!("Failed to save WhatsApp mode: {error}"),
                            OutputRole::Error,
                        );
                        return false;
                    }
                }
            }
            key => if clear {
                crate::gateway_setup::remove_env_key(key)
            } else {
                crate::gateway_setup::save_env_key(key, value)
            }
            .map_err(|error| error.to_string()),
        };

        match result {
            Ok(()) => {
                self.push_output(
                    if clear {
                        format!("{field} cleared.")
                    } else {
                        format!("{field} updated.")
                    },
                    OutputRole::System,
                );
                true
            }
            Err(error) => {
                self.push_output(
                    format!("Failed to save {field}: {error}"),
                    OutputRole::Error,
                );
                false
            }
        }
    }

    /// Advance spinner frame (called on every tick).
    ///
    /// Returns true only when the tick changed visible state and therefore
    /// needs a redraw.
    fn tick_spinner(&mut self) -> bool {
        self.poll_voice_recording_completion();
        if !self.animate_status_indicators {
            return false;
        }
        let mut animated = false;
        let advance_verb = match &mut self.display_state {
            DisplayState::AwaitingFirstToken { frame, .. } => {
                *frame = (*frame + 1) % SPINNER_FRAMES.len();
                animated = true;
                *frame == 0
            }
            DisplayState::Thinking { frame, .. } => {
                *frame = (*frame + 1) % SPINNER_FRAMES.len();
                animated = true;
                // Advance thinking verb on each full braille rotation
                *frame == 0
            }
            DisplayState::Streaming { .. } => {
                // Token streaming — redraw handled by check_responses
                false
            }
            DisplayState::ToolExec { frame, .. } => {
                *frame = (*frame + 1) % SPINNER_FRAMES.len();
                animated = true;
                false
            }
            DisplayState::BgOp { frame, .. } => {
                *frame = (*frame + 1) % SPINNER_FRAMES.len();
                animated = true;
                false
            }
            DisplayState::Idle
            | DisplayState::WaitingForClarify
            | DisplayState::WaitingForApproval { .. }
            | DisplayState::SecretCapture { .. }
            | DisplayState::ValueCapture { .. } => false,
        };
        if self.voice_recording.is_some()
            || self.voice_playback_active
            || self.voice_continuous_active
        {
            self.voice_presence_frame_idx =
                self.voice_presence_frame_idx.wrapping_add(1) % VOICE_RECORD_FRAMES.len();
            animated = true;
        }
        if !animated {
            return false;
        }
        if advance_verb {
            self.thinking_verb_idx = self.thinking_verb_idx.wrapping_add(1);
            // Advance kaomoji face every 3 verb changes (slower rotation)
            if self.thinking_verb_idx.is_multiple_of(3) {
                self.kaomoji_frame_idx = self.kaomoji_frame_idx.wrapping_add(1);
            }
        }

        // FP49: Refresh in-flight tool placeholder lines with elapsed time.
        //
        // After 3s, the "···" tail becomes "···  Xs" directly in the output-area
        // placeholder, so users see temporal feedback at their focal point (bottom
        // of output area) without having to look at the peripheral status bar.
        //
        // Performance: pending_tool_lines is usually 0–3 entries; the span rebuild
        // is a pure string transform (no I/O), so this is negligible at ~10fps.
        if !self.pending_tool_lines.is_empty() {
            // Collect updates first to avoid borrow conflicts.
            let updates: Vec<(String, usize, String, String, Option<String>, u64)> = self
                .pending_tool_lines
                .iter()
                .filter_map(|(id, entry)| {
                    let elapsed = self.active_tools.get(id)?.started_at.elapsed().as_secs();
                    if elapsed < 3 {
                        return None; // Nothing new to show yet.
                    }
                    let detail = self
                        .active_tools
                        .get(id)
                        .and_then(|s| s.last_detail.clone());
                    Some((
                        id.clone(),
                        entry.line_idx,
                        entry.tool_name.clone(),
                        entry.args_json.clone(),
                        detail,
                        elapsed,
                    ))
                })
                .collect();
            for (_id, line_idx, tool_name, args_json, detail, elapsed) in updates {
                if line_idx < self.output.len() {
                    let widths =
                        DisplayWidths::from_terminal_width(self.last_terminal_width as usize);
                    self.output[line_idx].prebuilt_spans =
                        Some(build_tool_running_line_width_elapsed(
                            &tool_name,
                            &args_json,
                            detail.as_deref(),
                            Some(elapsed),
                            &self.theme.tool_emojis,
                            &widths,
                        ));
                    self.output[line_idx].invalidate_render_cache();
                }
            }
        }

        true
    }

    fn needs_periodic_status_refresh(&self) -> bool {
        matches!(
            self.display_state,
            DisplayState::AwaitingFirstToken { .. }
                | DisplayState::Thinking { .. }
                | DisplayState::Streaming { .. }
                | DisplayState::ToolExec { .. }
                | DisplayState::BgOp { .. }
        ) || self.voice_recording.is_some()
            || self.voice_playback_active
            || self.voice_continuous_active
    }

    fn voice_presence_state(&self) -> Option<VoicePresenceState> {
        if let Some(recording) = &self.voice_recording {
            return Some(VoicePresenceState::Recording {
                elapsed_secs: recording.started_at.elapsed().as_secs(),
                continuous: recording.continuous_session,
            });
        }
        if self.voice_playback_active {
            return Some(VoicePresenceState::Speaking);
        }
        if self.voice_continuous_active {
            return Some(VoicePresenceState::Listening);
        }
        None
    }

    // ────────────────────────────────────────────────────────────────
    // Handlers (unchanged from before, just methods on the new App)
    // ────────────────────────────────────────────────────────────────

    fn handle_model_switch(&mut self, model: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let (provider_str, model_name) = match model.split_once('/') {
            Some((p, m)) => (p, m),
            None => {
                self.push_output(
                    format!("Invalid format: use 'provider/model-name' (e.g. copilot/gpt-4.1-mini). Got: '{model}'"),
                    OutputRole::Error,
                );
                return;
            }
        };
        let canonical = edgecrab_tools::vision_models::normalize_provider_name(provider_str);
        let new_provider = match edgecrab_tools::create_provider_for_model(&canonical, model_name) {
            Ok(p) => p,
            Err(e) => {
                self.push_output(
                    format!("Failed to create provider '{provider_str}': {e}"),
                    OutputRole::Error,
                );
                return;
            }
        };
        let model_clone = model.clone();
        let tx = self.response_tx.clone();
        self.display_state = DisplayState::BgOp {
            label: format!("Switching to {}…", model),
            frame: 0,
            started: Instant::now(),
        };
        self.needs_redraw = true;
        self.rt_handle.spawn(async move {
            agent.swap_model(model_clone.clone(), new_provider).await;
            let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::ModelSwitchDone {
                model: model_clone,
            }));
        });
    }

    fn open_cheap_model_selector(&mut self) {
        let smart_routing = self
            .agent
            .as_ref()
            .map(|agent| self.rt_handle.block_on(agent.smart_routing_config()))
            .unwrap_or_else(|| self.load_runtime_config().model.smart_routing);
        let current_model = smart_routing.cheap_model.trim().to_string();
        self.refresh_shared_model_selector_catalog(current_model, ModelSelectorTarget::Cheap);
    }

    fn handle_show_cheap_model(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let primary_model = self.rt_handle.block_on(agent.model());
        let smart_routing = self.rt_handle.block_on(agent.smart_routing_config());
        let text = if smart_routing.enabled && !smart_routing.cheap_model.trim().is_empty() {
            format!(
                "Smart routing:\n\
                 Enabled:          yes\n\
                 Primary model:    {primary_model}\n\
                 Cheap model:      {}\n\
                 Route policy:     short/simple turns prefer the cheap model\n\
                 Usage:            /cheap_model | /cheap_model status | /cheap_model off | /cheap_model <provider/model>",
                smart_routing.cheap_model,
            )
        } else {
            format!(
                "Smart routing:\n\
                 Enabled:          no\n\
                 Primary model:    {primary_model}\n\
                 Cheap model:      (not configured)\n\
                 Usage:            /cheap_model to pick a cheap model, then EdgeCrab will auto-route obviously simple turns."
            )
        };
        self.open_report_overlay(
            "Cheap Model Routing",
            "Smart routing and cheap-model fallback policy",
            text,
        );
    }

    fn handle_set_cheap_model(&mut self, spec: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let trimmed = spec.trim();
        if trimmed.is_empty()
            || trimmed.eq_ignore_ascii_case("open")
            || trimmed.eq_ignore_ascii_case("list")
        {
            self.open_cheap_model_selector();
            return;
        }

        let mut smart_routing = self.rt_handle.block_on(agent.smart_routing_config());

        if trimmed.eq_ignore_ascii_case("status") {
            self.handle_show_cheap_model();
            return;
        }

        if matches!(
            trimmed.to_ascii_lowercase().as_str(),
            "off" | "disable" | "disabled" | "reset" | "clear"
        ) {
            smart_routing.enabled = false;
            smart_routing.cheap_model.clear();
            smart_routing.cheap_base_url = None;
            smart_routing.cheap_api_key_env = None;
            let persisted = smart_routing.clone();
            let agent_clone = Arc::clone(&agent);
            self.rt_handle.block_on(async move {
                agent_clone.set_smart_routing_config(persisted).await;
            });
            match persist_smart_routing_to_config(&smart_routing) {
                Ok(()) => self.push_output(
                    "Smart routing disabled. Future turns will stay on the primary model until a cheap model is configured again.",
                    OutputRole::System,
                ),
                Err(err) => self.push_output(
                    format!("Smart routing disabled for this session, but config save failed: {err}"),
                    OutputRole::Error,
                ),
            }
            return;
        }

        let Some((parsed_provider, canonical_model)) = parse_selection_spec(trimmed) else {
            self.push_output(
                format!("Invalid format: use 'provider/model-name' or 'off'. Got: '{trimmed}'"),
                OutputRole::Error,
            );
            return;
        };

        let provider = canonical_provider(&parsed_provider);
        smart_routing.enabled = true;
        smart_routing.cheap_model = format!("{provider}/{canonical_model}");
        let persisted = smart_routing.clone();
        let agent_clone = Arc::clone(&agent);
        self.rt_handle.block_on(async move {
            agent_clone.set_smart_routing_config(persisted).await;
        });
        match persist_smart_routing_to_config(&smart_routing) {
            Ok(()) => self.push_output(
                format!(
                    "Cheap model set to {}. EdgeCrab will auto-route obviously simple turns to this model.",
                    smart_routing.cheap_model
                ),
                OutputRole::System,
            ),
            Err(err) => self.push_output(
                format!("Cheap model updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn open_moa_aggregator_selector(&mut self) {
        let moa = self.current_moa_config();
        self.refresh_shared_model_selector_catalog(
            moa.aggregator_model,
            ModelSelectorTarget::MoaAggregator,
        );
    }

    fn current_moa_config(&self) -> edgecrab_core::config::MoaConfig {
        self.agent
            .as_ref()
            .map(|agent| self.rt_handle.block_on(agent.moa_config()))
            .unwrap_or_else(|| self.load_runtime_config().moa)
    }

    fn current_toolset_filters(&self) -> (Vec<String>, Vec<String>) {
        let (enabled_toolsets, disabled_toolsets, _, _) = self.current_tool_filters();
        (enabled_toolsets, disabled_toolsets)
    }

    fn current_tool_filters(&self) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
        self.agent
            .as_ref()
            .map(|agent| self.rt_handle.block_on(agent.tool_filters()))
            .unwrap_or_else(|| {
                let config = self.load_runtime_config();
                (
                    config.tools.enabled_toolsets.unwrap_or_default(),
                    config.tools.disabled_toolsets.unwrap_or_default(),
                    config.tools.enabled_tools.unwrap_or_default(),
                    config.tools.disabled_tools.unwrap_or_default(),
                )
            })
    }

    fn current_tool_inventory(&self) -> Vec<ToolInventoryEntry> {
        self.agent
            .as_ref()
            .map(|agent| self.rt_handle.block_on(agent.tool_inventory()))
            .unwrap_or_default()
    }

    fn build_tool_manager_entries(&self, scope: ToolManagerScope) -> Vec<ToolManagerEntry> {
        let inventory = self.current_tool_inventory();
        let (enabled_toolsets, disabled_toolsets, enabled_tools, disabled_tools) =
            self.current_tool_filters();

        let mut tool_entries: Vec<ToolManagerEntry> = inventory
            .iter()
            .map(|entry| {
                let policy_source = if disabled_tools
                    .iter()
                    .any(|candidate| candidate == &entry.name)
                {
                    ToolPolicySource::ExplicitDisable
                } else if enabled_tools
                    .iter()
                    .any(|candidate| candidate == &entry.name)
                {
                    ToolPolicySource::ExplicitEnable
                } else {
                    ToolPolicySource::Default
                };

                let detail = format!(
                    "{} · {}",
                    entry.toolset,
                    if entry.exposed() {
                        "exposed"
                    } else if !entry.policy_enabled {
                        "disabled by policy"
                    } else if !entry.startup_available {
                        "startup unavailable"
                    } else {
                        "runtime gated"
                    }
                );

                ToolManagerEntry {
                    kind: ToolManagerItemKind::Tool,
                    name: entry.name.clone(),
                    toolset: entry.toolset.clone(),
                    description: entry.description.clone(),
                    detail,
                    tag: if entry.dynamic {
                        "dynamic".into()
                    } else {
                        "tool".into()
                    },
                    check_state: if entry.policy_enabled {
                        ToolManagerCheckState::On
                    } else {
                        ToolManagerCheckState::Off
                    },
                    policy_source,
                    exposed: entry.exposed(),
                    startup_available: entry.startup_available,
                    check_allowed: entry.check_allowed,
                    dynamic: entry.dynamic,
                    emoji: entry.emoji.clone(),
                    aliases: entry.aliases.clone(),
                    selected_tools: usize::from(entry.policy_enabled),
                    total_tools: 1,
                    exposed_tools: usize::from(entry.exposed()),
                }
            })
            .collect();
        tool_entries.sort_by(|left, right| left.name.cmp(&right.name));

        let mut toolset_entries = Vec::new();
        for (toolset, tools) in inventory.iter().fold(
            BTreeMap::<String, Vec<&ToolInventoryEntry>>::new(),
            |mut acc, entry| {
                acc.entry(entry.toolset.clone()).or_default().push(entry);
                acc
            },
        ) {
            let total_tools = tools.len();
            let selected_tools = tools.iter().filter(|tool| tool.policy_enabled).count();
            let exposed_tools = tools.iter().filter(|tool| tool.exposed()).count();
            let toolset_enabled = edgecrab_tools::toolsets::toolset_enabled(
                Some(&enabled_toolsets),
                Some(&disabled_toolsets),
                &toolset,
            );
            let explicit_enabled = tools
                .iter()
                .filter(|tool| {
                    enabled_tools
                        .iter()
                        .any(|candidate| candidate == &tool.name)
                })
                .count();
            let explicit_disabled = tools
                .iter()
                .filter(|tool| {
                    disabled_tools
                        .iter()
                        .any(|candidate| candidate == &tool.name)
                })
                .count();
            let check_state = if explicit_enabled > 0 || explicit_disabled > 0 {
                ToolManagerCheckState::Mixed
            } else if toolset_enabled {
                ToolManagerCheckState::On
            } else {
                ToolManagerCheckState::Off
            };
            let policy_source =
                if !toolset_entries_referencing(&toolset, &disabled_toolsets).is_empty() {
                    ToolPolicySource::ExplicitDisable
                } else if toolset_enabled
                    && !enabled_toolsets.is_empty()
                    && !edgecrab_tools::toolsets::contains_all_sentinel(&enabled_toolsets)
                {
                    ToolPolicySource::ExplicitEnable
                } else {
                    ToolPolicySource::Default
                };
            let preview = tools
                .iter()
                .take(4)
                .map(|tool| tool.name.as_str())
                .collect::<Vec<_>>()
                .join(", ");
            toolset_entries.push(ToolManagerEntry {
                kind: ToolManagerItemKind::Toolset,
                name: toolset.clone(),
                toolset: toolset.clone(),
                description: preview,
                detail: format!(
                    "{selected_tools}/{total_tools} selected · {exposed_tools}/{total_tools} exposed"
                ),
                tag: "toolset".into(),
                check_state,
                policy_source,
                exposed: exposed_tools > 0,
                startup_available: true,
                check_allowed: true,
                dynamic: tools.iter().any(|tool| tool.dynamic),
                emoji: "◈".into(),
                aliases: Vec::new(),
                selected_tools,
                total_tools,
                exposed_tools,
            });
        }

        match scope {
            ToolManagerScope::All => {
                toolset_entries.sort_by(|left, right| left.name.cmp(&right.name));
                toolset_entries.extend(tool_entries);
                toolset_entries
            }
            ToolManagerScope::Toolsets => {
                toolset_entries.sort_by(|left, right| left.name.cmp(&right.name));
                toolset_entries
            }
            ToolManagerScope::Tools => tool_entries,
        }
    }

    fn refresh_tool_manager_entries(&mut self) -> bool {
        if self.agent.is_none() {
            self.push_output(
                "Tool manager requires an active agent session.",
                OutputRole::Error,
            );
            return false;
        }
        let entries = self.build_tool_manager_entries(self.tool_manager_scope);
        if self.tool_manager.active {
            self.tool_manager.replace_items_preserving_state(entries);
        } else {
            self.tool_manager.set_items(entries);
        }
        self.needs_redraw = true;
        true
    }

    fn open_tool_manager(&mut self, mode: ToolManagerMode) {
        self.tool_manager_scope = ToolManagerScope::from_mode(mode);
        if !self.refresh_tool_manager_entries() {
            return;
        }
        self.reset_split_detail_scroll(DetailSurface::ToolManager);
        self.tool_manager_status_note =
            Some("Space toggles policy. Tab focuses panes. Left/Right changes scope. R restores defaults.".into());
        self.tool_manager.active = true;
        self.needs_redraw = true;
    }

    fn persist_and_apply_tool_filters(
        &mut self,
        enabled_toolsets: Vec<String>,
        disabled_toolsets: Vec<String>,
        enabled_tools: Vec<String>,
        disabled_tools: Vec<String>,
    ) -> Result<(), String> {
        if let Some(agent) = self.agent.as_ref() {
            let agent = Arc::clone(agent);
            let agent_enabled_toolsets = enabled_toolsets.clone();
            let agent_disabled_toolsets = disabled_toolsets.clone();
            let agent_enabled_tools = enabled_tools.clone();
            let agent_disabled_tools = disabled_tools.clone();
            self.rt_handle.block_on(async move {
                agent
                    .set_tool_filters(
                        agent_enabled_toolsets,
                        agent_disabled_toolsets,
                        agent_enabled_tools,
                        agent_disabled_tools,
                    )
                    .await;
            });
        }

        let enabled_toolsets_option = (!enabled_toolsets.is_empty()).then_some(enabled_toolsets);
        let disabled_toolsets_option = (!disabled_toolsets.is_empty()).then_some(disabled_toolsets);
        let enabled_tools_option = (!enabled_tools.is_empty()).then_some(enabled_tools);
        let disabled_tools_option = (!disabled_tools.is_empty()).then_some(disabled_tools);

        persist_tool_filters_to_config(
            enabled_toolsets_option.as_deref(),
            disabled_toolsets_option.as_deref(),
            enabled_tools_option.as_deref(),
            disabled_tools_option.as_deref(),
        )
        .map_err(|err| err.to_string())
    }

    fn refresh_agent_plugin_runtime(&mut self) -> Result<(), String> {
        let Some(agent) = self.agent.as_ref().cloned() else {
            return Ok(());
        };

        let config = self.load_runtime_config();
        let registry =
            self.rt_handle
                .block_on(crate::runtime::build_tool_registry_with_mcp_discovery(
                    &config,
                ));
        let plugins_config = config.plugins.clone();
        self.rt_handle.block_on(async move {
            agent.set_plugins_config(plugins_config).await;
            agent.set_tool_registry(registry).await;
        });

        if self.tool_manager.active {
            let _ = self.refresh_tool_manager_entries();
        }
        if self.plugin_toggle.active {
            let _ = self.refresh_plugin_toggle_entries();
        }
        Ok(())
    }

    fn toggle_tool_manager_selected(&mut self) {
        let Some(entry) = self.tool_manager.current().cloned() else {
            return;
        };
        let (mut enabled_toolsets, mut disabled_toolsets, mut enabled_tools, mut disabled_tools) =
            self.current_tool_filters();

        match entry.kind {
            ToolManagerItemKind::Toolset => {
                let is_enabled = edgecrab_tools::toolsets::toolset_enabled(
                    Some(&enabled_toolsets),
                    Some(&disabled_toolsets),
                    &entry.toolset,
                );
                if is_enabled {
                    disabled_toolsets.push(entry.toolset.clone());
                } else {
                    let mut enabled_option =
                        (!enabled_toolsets.is_empty()).then_some(enabled_toolsets);
                    let mut disabled_option =
                        (!disabled_toolsets.is_empty()).then_some(disabled_toolsets);
                    edgecrab_tools::toolsets::ensure_literal_toolset_enabled(
                        &mut enabled_option,
                        &mut disabled_option,
                        &entry.toolset,
                    );
                    enabled_toolsets = enabled_option.unwrap_or_default();
                    disabled_toolsets = disabled_option.unwrap_or_default();
                }
                normalize_tool_policy_list(&mut enabled_toolsets);
                normalize_tool_policy_list(&mut disabled_toolsets);
            }
            ToolManagerItemKind::Tool => {
                let is_enabled = edgecrab_tools::toolsets::tool_enabled(
                    Some(&enabled_toolsets),
                    Some(&disabled_toolsets),
                    Some(&enabled_tools),
                    Some(&disabled_tools),
                    &entry.name,
                    &entry.toolset,
                );
                if is_enabled {
                    disabled_tools.push(entry.name.clone());
                    enabled_tools.retain(|candidate| candidate != &entry.name);
                } else {
                    enabled_tools.push(entry.name.clone());
                    disabled_tools.retain(|candidate| candidate != &entry.name);
                }
                normalize_tool_policy_list(&mut enabled_tools);
                normalize_tool_policy_list(&mut disabled_tools);
            }
        }

        match self.persist_and_apply_tool_filters(
            enabled_toolsets,
            disabled_toolsets,
            enabled_tools,
            disabled_tools,
        ) {
            Ok(()) => {
                self.tool_manager_status_note = Some(format!(
                    "{} {}",
                    if matches!(entry.kind, ToolManagerItemKind::Toolset) {
                        "Updated toolset policy for"
                    } else {
                        "Updated tool policy for"
                    },
                    entry.name
                ));
                let _ = self.refresh_tool_manager_entries();
            }
            Err(err) => {
                self.push_output(
                    format!("Failed to update tool policy: {err}"),
                    OutputRole::Error,
                );
            }
        }
    }

    fn reset_tool_manager_policy(&mut self) {
        match self.persist_and_apply_tool_filters(Vec::new(), Vec::new(), Vec::new(), Vec::new()) {
            Ok(()) => {
                self.tool_manager_status_note = Some("Tool policy reset to defaults.".to_string());
                let _ = self.refresh_tool_manager_entries();
                self.push_output("Tool policy reset to defaults.", OutputRole::System);
            }
            Err(err) => {
                self.push_output(
                    format!("Failed to reset tool policy: {err}"),
                    OutputRole::Error,
                );
            }
        }
    }

    fn plugin_toggle_platforms(&self) -> Vec<String> {
        vec![
            "cli".into(),
            "telegram".into(),
            "discord".into(),
            "slack".into(),
            "matrix".into(),
            "whatsapp".into(),
            "signal".into(),
            "mattermost".into(),
            "dingtalk".into(),
            "homeassistant".into(),
            "email".into(),
            "webhook".into(),
            "api_server".into(),
        ]
    }

    fn next_plugin_toggle_scope(&self) -> PluginScope {
        let platforms = self.plugin_toggle_platforms();
        match &self.plugin_toggle_scope {
            PluginScope::Global => platforms
                .first()
                .cloned()
                .map(PluginScope::Platform)
                .unwrap_or(PluginScope::Global),
            PluginScope::Platform(current) => {
                let next_idx = platforms
                    .iter()
                    .position(|platform| platform == current)
                    .map(|idx| idx + 1)
                    .unwrap_or(0);
                platforms
                    .get(next_idx)
                    .cloned()
                    .map(PluginScope::Platform)
                    .unwrap_or(PluginScope::Global)
            }
        }
    }

    fn previous_plugin_toggle_scope(&self) -> PluginScope {
        let platforms = self.plugin_toggle_platforms();
        match &self.plugin_toggle_scope {
            PluginScope::Global => platforms
                .last()
                .cloned()
                .map(PluginScope::Platform)
                .unwrap_or(PluginScope::Global),
            PluginScope::Platform(current) => {
                let prev_idx = platforms
                    .iter()
                    .position(|platform| platform == current)
                    .and_then(|idx| idx.checked_sub(1));
                prev_idx
                    .and_then(|idx| platforms.get(idx).cloned().map(PluginScope::Platform))
                    .unwrap_or(PluginScope::Global)
            }
        }
    }

    fn build_plugin_toggle_entries(&self, scope: &PluginScope) -> Vec<PluginToggleEntry> {
        let config = self.load_runtime_config();
        let mut manager = crate::plugins::PluginManager::new();
        manager.discover_all();
        let platform = match scope {
            PluginScope::Global => None,
            PluginScope::Platform(platform) => Some(platform.as_str()),
        };

        let mut entries = manager
            .plugins()
            .iter()
            .map(|plugin| {
                PluginToggleEntry::from_plugin(
                    &plugin.clone(),
                    config.is_plugin_enabled(&plugin.name, platform),
                )
            })
            .collect::<Vec<_>>();
        entries.sort_by(|left, right| {
            matches!(right.check_state, PluginCheckState::On)
                .cmp(&matches!(left.check_state, PluginCheckState::On))
                .then_with(|| left.runtime_status.cmp(&right.runtime_status))
                .then_with(|| left.name.cmp(&right.name))
        });
        entries
    }

    fn refresh_plugin_toggle_entries(&mut self) -> bool {
        let entries = self.build_plugin_toggle_entries(&self.plugin_toggle_scope);
        if self.plugin_toggle.active {
            self.plugin_toggle.replace_items_preserving_state(entries);
        } else {
            self.plugin_toggle.set_items(entries);
        }
        self.needs_redraw = true;
        true
    }

    fn open_plugin_toggle(&mut self, scope: PluginScope) {
        self.remote_plugin_browser.selector.active = false;
        self.remote_plugin_browser.action_in_flight = None;
        self.plugin_toggle_scope = scope;
        let _ = self.refresh_plugin_toggle_entries();
        self.reset_split_detail_scroll(DetailSurface::PluginToggle);
        self.plugin_toggle_status_note = Some(
            "Space stages changes. Enter saves. Tab focuses panes. Left/Right changes scope. R opens remote search.".into(),
        );
        self.plugin_toggle.activate();
        self.needs_redraw = true;
    }

    fn toggle_plugin_selected(&mut self) {
        let Some(entry_idx) = self
            .plugin_toggle
            .filtered
            .get(self.plugin_toggle.selected)
            .copied()
        else {
            return;
        };
        if let Some(entry) = self.plugin_toggle.items.get_mut(entry_idx) {
            entry.check_state = entry.check_state.toggle();
        }
        self.plugin_toggle_status_note = Some(plugin_toggle_status_line(&self.plugin_toggle.items));
        self.needs_redraw = true;
    }

    fn confirm_plugin_toggle(&mut self) {
        if !self
            .plugin_toggle
            .items
            .iter()
            .any(PluginToggleEntry::has_pending_change)
        {
            self.plugin_toggle.active = false;
            self.plugin_toggle_status_note = Some("No plugin changes to save.".into());
            self.needs_redraw = true;
            return;
        }
        let config_path = edgecrab_core::edgecrab_home().join("config.yaml");
        let mut config = self.load_runtime_config();
        let target_names = self
            .plugin_toggle
            .items
            .iter()
            .map(|entry| entry.name.clone())
            .collect::<Vec<_>>();

        match &self.plugin_toggle_scope {
            PluginScope::Global => {
                config
                    .plugins
                    .disabled
                    .retain(|name| !target_names.iter().any(|candidate| candidate == name));
                for entry in &self.plugin_toggle.items {
                    if matches!(entry.check_state, PluginCheckState::Off) {
                        config.plugins.disabled.push(entry.name.clone());
                    }
                }
                config.plugins.disabled.sort();
                config.plugins.disabled.dedup();
            }
            PluginScope::Platform(platform) => {
                let disabled = config
                    .plugins
                    .platform_disabled
                    .entry(platform.to_ascii_lowercase())
                    .or_default();
                disabled.retain(|name| !target_names.iter().any(|candidate| candidate == name));
                for entry in &self.plugin_toggle.items {
                    if matches!(entry.check_state, PluginCheckState::Off) {
                        disabled.push(entry.name.clone());
                    }
                }
                disabled.sort();
                disabled.dedup();
            }
        }

        match config.save_to(&config_path) {
            Ok(()) => {
                self.plugin_toggle_status_note = Some(format!(
                    "Saved plugin state for {} scope.",
                    self.plugin_toggle_scope.title()
                ));
                if let Err(error) = self.refresh_agent_plugin_runtime() {
                    self.push_output(
                        format!("Plugin runtime refresh failed: {error}"),
                        OutputRole::Error,
                    );
                }
                self.plugin_toggle.active = false;
                self.push_output("Plugin configuration saved.", OutputRole::System);
            }
            Err(error) => {
                self.push_output(
                    format!("Failed to save plugin configuration: {error}"),
                    OutputRole::Error,
                );
            }
        }
        self.needs_redraw = true;
    }

    fn toggle_named_plugin(&mut self, name: &str, platform: Option<String>) {
        let config_path = edgecrab_core::edgecrab_home().join("config.yaml");
        let mut config = self.load_runtime_config();
        let enabled = config.is_plugin_enabled(name, platform.as_deref());

        match &platform {
            Some(platform_name) => {
                let disabled = config
                    .plugins
                    .platform_disabled
                    .entry(platform_name.to_ascii_lowercase())
                    .or_default();
                disabled.retain(|candidate| candidate != name);
                if enabled {
                    disabled.push(name.to_string());
                    disabled.sort();
                    disabled.dedup();
                }
            }
            None => {
                config
                    .plugins
                    .disabled
                    .retain(|candidate| candidate != name);
                if enabled {
                    config.plugins.disabled.push(name.to_string());
                    config.plugins.disabled.sort();
                    config.plugins.disabled.dedup();
                }
            }
        }

        match config.save_to(&config_path) {
            Ok(()) => {
                if let Err(error) = self.refresh_agent_plugin_runtime() {
                    self.push_output(
                        format!("Plugin runtime refresh failed: {error}"),
                        OutputRole::Error,
                    );
                }
                self.push_output(
                    format!(
                        "{} plugin `{name}`{}.",
                        if enabled { "Disabled" } else { "Enabled" },
                        platform
                            .as_ref()
                            .map(|scope| format!(" for `{scope}`"))
                            .unwrap_or_default()
                    ),
                    OutputRole::System,
                );
            }
            Err(error) => self.push_output(
                format!("Failed to update plugin `{name}`: {error}"),
                OutputRole::Error,
            ),
        }
    }

    fn current_moa_availability(&self) -> MoaAvailability {
        let moa = self.current_moa_config();
        let (enabled_toolsets, disabled_toolsets) = self.current_toolset_filters();
        MoaAvailability {
            feature_enabled: moa.enabled,
            toolset_enabled: edgecrab_tools::toolsets::toolset_enabled(
                Some(&enabled_toolsets),
                Some(&disabled_toolsets),
                "moa",
            ),
        }
    }

    fn describe_moa_toolset_policy(&self) -> Option<String> {
        let (enabled_toolsets, disabled_toolsets) = self.current_toolset_filters();
        let disabled_blockers = toolset_entries_referencing("moa", &disabled_toolsets);
        if !disabled_blockers.is_empty() {
            return Some(format!(
                "`tools.disabled_toolsets` still blocks `moa` via {}. Remove that entry or edit the toolset policy.",
                disabled_blockers.join(", ")
            ));
        }

        if !edgecrab_tools::toolsets::toolset_enabled(
            Some(&enabled_toolsets),
            Some(&disabled_toolsets),
            "moa",
        ) && !enabled_toolsets.is_empty()
            && !edgecrab_tools::toolsets::contains_all_sentinel(&enabled_toolsets)
        {
            return Some(
                "`tools.enabled_toolsets` still excludes `moa`. Add the literal `moa` entry or use an alias that expands to it."
                    .into(),
            );
        }

        None
    }

    fn apply_moa_config_update(
        &mut self,
        moa: edgecrab_core::config::MoaConfig,
        ensure_toolset_reachable: bool,
    ) -> Result<edgecrab_tools::toolsets::ToolsetEnableSync, String> {
        let moa = moa.sanitized();
        let sync = if ensure_toolset_reachable && moa.enabled {
            self.ensure_moa_toolset_is_reachable()?
        } else {
            edgecrab_tools::toolsets::ToolsetEnableSync::default()
        };

        if let Some(agent) = self.agent.as_ref() {
            let agent = Arc::clone(agent);
            let persisted = moa.clone();
            self.rt_handle.block_on(async move {
                agent.set_moa_config(persisted).await;
            });
        }

        persist_moa_config_to_config(&moa).map_err(|err| err.to_string())?;
        Ok(sync)
    }

    fn emit_moa_enabled_feedback(
        &mut self,
        base_message: String,
        sync: edgecrab_tools::toolsets::ToolsetEnableSync,
    ) {
        let availability = self.current_moa_availability();
        if availability.effective() {
            let mut details = Vec::new();
            if sync.added_to_enabled {
                details.push("added `moa` to `tools.enabled_toolsets`");
            }
            if sync.removed_from_disabled {
                details.push("removed the literal `moa` block from `tools.disabled_toolsets`");
            }
            let message = if details.is_empty() {
                base_message
            } else {
                format!(
                    "{base_message} Tool exposure was repaired: {}.",
                    details.join("; ")
                )
            };
            self.push_output(message, OutputRole::System);
            return;
        }

        let detail = self
            .describe_moa_toolset_policy()
            .unwrap_or_else(|| "the current toolset policy still hides it from the model.".into());
        self.push_output(
            format!("{base_message} However `moa` is still unavailable to the model: {detail}"),
            OutputRole::Error,
        );
    }

    fn ensure_moa_toolset_is_reachable(
        &mut self,
    ) -> Result<edgecrab_tools::toolsets::ToolsetEnableSync, String> {
        let (enabled_toolsets, disabled_toolsets) = self.current_toolset_filters();
        let mut enabled_option = if enabled_toolsets.is_empty() {
            None
        } else {
            Some(enabled_toolsets)
        };
        let mut disabled_option = if disabled_toolsets.is_empty() {
            None
        } else {
            Some(disabled_toolsets)
        };
        let sync = edgecrab_tools::toolsets::ensure_literal_toolset_enabled(
            &mut enabled_option,
            &mut disabled_option,
            "moa",
        );

        let enabled_vec = enabled_option.clone().unwrap_or_default();
        let disabled_vec = disabled_option.clone().unwrap_or_default();

        if let Some(agent) = self.agent.as_ref() {
            let agent = Arc::clone(agent);
            self.rt_handle.block_on(async move {
                agent.set_toolset_filters(enabled_vec, disabled_vec).await;
            });
        }

        persist_toolset_filters_to_config(enabled_option.as_deref(), disabled_option.as_deref())
            .map_err(|err| err.to_string())?;

        Ok(sync)
    }

    fn open_moa_reference_selector_for_mode(&mut self, mode: MoaReferenceSelectorMode) {
        let moa = self.current_moa_config();
        self.moa_reference_selected = moa.reference_models.iter().cloned().collect();

        let mut entries = build_model_selector_entries(&ModelCatalog::grouped_catalog(), None);
        match mode {
            MoaReferenceSelectorMode::EditRoster => {}
            MoaReferenceSelectorMode::AddExpert => {
                entries.retain(|entry| !self.moa_reference_selected.contains(&entry.display));
            }
            MoaReferenceSelectorMode::RemoveExpert => {
                entries.retain(|entry| self.moa_reference_selected.contains(&entry.display));
            }
        }

        if entries.is_empty() {
            let message = match mode {
                MoaReferenceSelectorMode::EditRoster => {
                    "No model catalog entries are available to build an MoA roster.".to_string()
                }
                MoaReferenceSelectorMode::AddExpert => {
                    "No additional models are available to add to the MoA expert roster."
                        .to_string()
                }
                MoaReferenceSelectorMode::RemoveExpert => {
                    "The MoA expert roster is empty, so there is nothing to remove.".to_string()
                }
            };
            self.push_output(message, OutputRole::System);
            return;
        }

        let primary = match mode {
            MoaReferenceSelectorMode::EditRoster | MoaReferenceSelectorMode::RemoveExpert => moa
                .reference_models
                .first()
                .cloned()
                .unwrap_or_else(|| entries[0].display.clone()),
            MoaReferenceSelectorMode::AddExpert => entries[0].display.clone(),
        };

        self.moa_reference_selector_mode = mode;
        self.moa_reference_selector.set_items(entries);
        self.moa_reference_selector.activate_with_primary(&primary);
        self.needs_redraw = true;
    }

    fn open_moa_reference_selector(&mut self) {
        self.open_moa_reference_selector_for_mode(MoaReferenceSelectorMode::EditRoster);
    }

    fn open_moa_add_expert_selector(&mut self) {
        self.open_moa_reference_selector_for_mode(MoaReferenceSelectorMode::AddExpert);
    }

    fn open_moa_remove_expert_selector(&mut self) {
        self.open_moa_reference_selector_for_mode(MoaReferenceSelectorMode::RemoveExpert);
    }

    fn handle_set_moa_enabled(&mut self, enabled: bool) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let mut moa = self.rt_handle.block_on(agent.moa_config());
        moa.enabled = enabled;
        match self.apply_moa_config_update(moa.clone(), enabled) {
            Ok(sync) => {
                if enabled {
                    self.emit_moa_enabled_feedback(
                        format!(
                            "Mixture-of-Agents enabled. Future `moa` tool calls will use aggregator {} and {} reference model(s).",
                            moa.aggregator_model,
                            moa.reference_models.len()
                        ),
                        sync,
                    );
                } else {
                    self.push_output(
                        "Mixture-of-Agents disabled. The tool is hidden from future turns until you run `/moa on` or edit the MoA defaults again.",
                        OutputRole::System,
                    );
                }
            }
            Err(err) => self.push_output(
                if enabled {
                    format!(
                        "Mixture-of-Agents enabled for this session, but config save failed: {err}"
                    )
                } else {
                    format!(
                        "Mixture-of-Agents disabled for this session, but config save failed: {err}"
                    )
                },
                OutputRole::Error,
            ),
        }
    }

    fn handle_show_moa_config(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let moa = self.rt_handle.block_on(agent.moa_config());
        let current_model = self.rt_handle.block_on(agent.model());
        let availability = self.current_moa_availability();
        let references = if moa.reference_models.is_empty() {
            "(none)".to_string()
        } else {
            moa.reference_models
                .iter()
                .map(|model| format!("  - {model}"))
                .collect::<Vec<_>>()
                .join("\n")
        };
        let toolset_note = self
            .describe_moa_toolset_policy()
            .unwrap_or_else(|| "The current toolset policy exposes `moa` to the model.".into());
        let text = format!(
            "Mixture-of-Agents defaults:\n\
             Config enabled:   {}\n\
             Toolset exposed:  {}\n\
             Effective:        {}\n\
             Current chat model: {}\n\
             Aggregator:       {}\n\
             Reference count:  {}\n\
             Reference roster:\n{}\n\
             Toolset policy:   {}\n\
             Runtime safety:   MoA auto-adds the current chat model as a last-chance expert \
                               and falls back to it for aggregation if the saved aggregator \
                               cannot run.\n\
             \nUsage:\n\
              /moa on                     enable the tool with the saved defaults\n\
              /moa off                    disable the tool without losing the roster\n\
              /moa aggregator             open the aggregator selector\n\
              /moa experts                open the full expert roster editor\n\
              /moa add [provider/model]   add one expert from the catalog or by id\n\
              /moa remove [provider/model] remove one configured expert\n\
              /moa reset                  reset to a safe baseline for the current chat model\n\
              Tool name:                   `moa` (legacy alias: `mixture_of_agents`)",
            if availability.feature_enabled {
                "yes"
            } else {
                "no"
            },
            if availability.toolset_enabled {
                "yes"
            } else {
                "no"
            },
            if availability.effective() {
                "yes"
            } else {
                "no"
            },
            current_model,
            moa.aggregator_model,
            moa.reference_models.len(),
            references,
            toolset_note,
        );
        self.open_report_overlay(
            "Mixture-of-Agents",
            "Default MoA runtime, roster, and toolset exposure",
            text,
        );
    }

    fn handle_set_moa_aggregator(&mut self, spec: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let trimmed = spec.trim();
        if trimmed.is_empty()
            || trimmed.eq_ignore_ascii_case("open")
            || trimmed.eq_ignore_ascii_case("list")
        {
            self.open_moa_aggregator_selector();
            return;
        }

        let mut moa = self.rt_handle.block_on(agent.moa_config());
        moa.enabled = true;
        if matches!(trimmed.to_ascii_lowercase().as_str(), "default" | "reset") {
            moa.aggregator_model =
                edgecrab_tools::tools::mixture_of_agents::DEFAULT_AGGREGATOR_MODEL.to_string();
        } else {
            let Some((provider, model)) = parse_selection_spec(trimmed) else {
                self.push_output(
                    format!("Invalid format: use 'provider/model-name'. Got: '{trimmed}'"),
                    OutputRole::Error,
                );
                return;
            };
            moa.aggregator_model = format!("{}/{}", canonical_provider(&provider), model);
        }

        match self.apply_moa_config_update(moa.clone(), true) {
            Ok(sync) => self.emit_moa_enabled_feedback(
                format!("MoA aggregator set to {}.", moa.aggregator_model),
                sync,
            ),
            Err(err) => self.push_output(
                format!("MoA aggregator updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_save_moa_reference_selection(&mut self, selected: Vec<String>) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        if selected.is_empty() {
            self.push_output(
                "MoA needs at least one reference model. Use Space to select one or more entries before pressing Enter.",
                OutputRole::Error,
            );
            return;
        }

        let mut moa = self.rt_handle.block_on(agent.moa_config());
        moa.enabled = true;
        moa.reference_models = selected;
        match self.apply_moa_config_update(moa.clone(), true) {
            Ok(sync) => self.emit_moa_enabled_feedback(
                format!(
                    "MoA reference roster updated ({} models).",
                    moa.reference_models.len()
                ),
                sync,
            ),
            Err(err) => self.push_output(
                format!("MoA references updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_add_moa_reference(&mut self, spec: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let trimmed = spec.trim();
        if trimmed.is_empty() {
            self.open_moa_add_expert_selector();
            return;
        }

        let Some((provider, model)) = parse_selection_spec(trimmed) else {
            self.push_output(
                format!("Invalid format: use 'provider/model-name'. Got: '{trimmed}'"),
                OutputRole::Error,
            );
            return;
        };

        let mut moa = self.rt_handle.block_on(agent.moa_config());
        moa.enabled = true;
        let selection = format!("{}/{}", canonical_provider(&provider), model);
        if !moa
            .reference_models
            .iter()
            .any(|candidate| candidate == &selection)
        {
            moa.reference_models.push(selection.clone());
        }
        match self.apply_moa_config_update(moa, true) {
            Ok(sync) => self.emit_moa_enabled_feedback(
                format!("Added {selection} to the MoA reference roster."),
                sync,
            ),
            Err(err) => self.push_output(
                format!("MoA reference updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_remove_moa_reference(&mut self, spec: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let trimmed = spec.trim();
        if trimmed.is_empty() {
            self.open_moa_remove_expert_selector();
            return;
        }

        let Some((provider, model)) = parse_selection_spec(trimmed) else {
            self.push_output(
                format!("Invalid format: use 'provider/model-name'. Got: '{trimmed}'"),
                OutputRole::Error,
            );
            return;
        };

        let selection = format!("{}/{}", canonical_provider(&provider), model);
        let mut moa = self.rt_handle.block_on(agent.moa_config());
        moa.enabled = true;
        moa.reference_models
            .retain(|candidate| candidate != &selection);
        if moa.reference_models.is_empty() {
            self.push_output(
                "MoA needs at least one reference model. Use /moa experts to pick a replacement before removing the last entry.",
                OutputRole::Error,
            );
            return;
        }

        match self.apply_moa_config_update(moa, true) {
            Ok(sync) => self.emit_moa_enabled_feedback(
                format!("Removed {selection} from the MoA reference roster."),
                sync,
            ),
            Err(err) => self.push_output(
                format!("MoA reference updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_reset_moa_config(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let current_model = self.rt_handle.block_on(agent.model());
        let recommended =
            edgecrab_tools::tools::mixture_of_agents::recommended_moa_config_for_model_spec(
                &current_model,
            );
        let moa = edgecrab_core::config::MoaConfig {
            enabled: recommended.enabled,
            reference_models: recommended.reference_models,
            aggregator_model: recommended.aggregator_model,
        };
        match self.apply_moa_config_update(moa, true) {
            Ok(sync) => self.emit_moa_enabled_feedback(
                format!(
                    "MoA defaults reset to a safe baseline for {}. Add more experts with `/moa add` or `/moa experts` when you want a broader roster.",
                    current_model
                ),
                sync,
            ),
            Err(err) => self.push_output(
                format!("MoA defaults restored for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_show_vision_model(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let current_model = self.rt_handle.block_on(agent.model());
        let auxiliary = self.rt_handle.block_on(agent.auxiliary_config());
        let text = match (auxiliary.provider.as_deref(), auxiliary.model.as_deref()) {
            (Some(provider), Some(model)) => format!(
                "Vision routing:\n\
                 Dedicated vision model: {}/{}\n\
                 Chat model fallback:    {}\n\
                 Mode:                   explicit override{}{}",
                match provider {
                    "vscode-copilot" => "copilot",
                    "gemini" => "google",
                    other => other,
                },
                model,
                current_model,
                auxiliary
                    .base_url
                    .as_deref()
                    .filter(|value| !value.trim().is_empty())
                    .map(|value| format!("\nBase URL:                {value}"))
                    .unwrap_or_default(),
                auxiliary
                    .api_key_env
                    .as_deref()
                    .filter(|value| !value.trim().is_empty())
                    .map(|value| format!("\nAPI key env:             {value}"))
                    .unwrap_or_default(),
            ),
            _ => format!(
                "Vision routing:\n\
                 Dedicated vision model: auto\n\
                 Chat model fallback:    {}\n\
                 Current model vision:   {}\n\
                 Mode:                   use current model when declared vision-capable, otherwise fail over to configured vision backends",
                current_model,
                if current_model_supports_vision(&current_model) {
                    "yes"
                } else {
                    "no"
                }
            ),
        };
        self.open_report_overlay(
            "Vision Routing",
            "Dedicated vision backend and fallback behavior",
            text,
        );
    }

    fn handle_set_vision_model(&mut self, spec: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let trimmed = spec.trim();
        if trimmed.eq_ignore_ascii_case("auto") || trimmed.eq_ignore_ascii_case("off") {
            let auxiliary = edgecrab_core::config::AuxiliaryConfig::default();
            let agent_clone = Arc::clone(&agent);
            self.rt_handle.block_on(async move {
                agent_clone.set_auxiliary_config(auxiliary).await;
            });
            match persist_vision_model_to_config(&edgecrab_core::config::AuxiliaryConfig::default())
            {
                Ok(()) => self.push_output(
                    "Vision model reset to auto. EdgeCrab will reuse the current chat model when it is declared vision-capable, otherwise it will fall back to configured vision backends."
                        .to_string(),
                    OutputRole::System,
                ),
                Err(err) => self.push_output(
                    format!("Vision model updated for this session, but config save failed: {err}"),
                    OutputRole::Error,
                ),
            }
            return;
        }

        let Some((parsed_provider, canonical_model)) = parse_selection_spec(trimmed) else {
            self.push_output(
                format!("Invalid format: use 'provider/model-name' or 'auto'. Got: '{trimmed}'"),
                OutputRole::Error,
            );
            return;
        };
        let provider = canonical_provider(&parsed_provider);
        let model = canonical_model;
        let display_provider = match provider.as_str() {
            "vscode-copilot" => "copilot",
            "gemini" => "google",
            other => other,
        };

        let auxiliary = edgecrab_core::config::AuxiliaryConfig {
            provider: Some(provider.clone()),
            model: Some(model.clone()),
            base_url: None,
            api_key_env: None,
        };
        let agent_clone = Arc::clone(&agent);
        self.rt_handle.block_on(async move {
            agent_clone.set_auxiliary_config(auxiliary.clone()).await;
        });
        match persist_vision_model_to_config(&edgecrab_core::config::AuxiliaryConfig {
            provider: Some(provider.clone()),
            model: Some(model.clone()),
            base_url: None,
            api_key_env: None,
        }) {
            Ok(()) => self.push_output(
                format!(
                    "Dedicated vision model set to {display_provider}/{model}. Future image analysis will prefer this backend."
                ),
                OutputRole::System,
            ),
            Err(err) => self.push_output(
                format!("Vision model updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_show_image_model(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let (_, _, image_generation) = self.rt_handle.block_on(agent.media_config());
        let text = format!(
            "Image generation routing:\n\
             Default backend: {}/{}\n\
             Built-in default: {}\n\
             Storage:         persisted in config.yaml under image_generation\n\
             Usage:           /image_model | /image_model status | /image_model <provider>/<model> | /image_model default",
            image_generation.provider,
            image_generation.model,
            cli_image_models::default_selection_spec(),
        );
        self.open_report_overlay(
            "Image Routing",
            "Default image-generation backend and persistence",
            text,
        );
    }

    fn handle_set_image_model(&mut self, spec: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let trimmed = spec.trim();
        if trimmed.eq_ignore_ascii_case("list") || trimmed.eq_ignore_ascii_case("open") {
            self.open_image_model_selector();
            return;
        }

        if trimmed.eq_ignore_ascii_case("default")
            || trimmed.eq_ignore_ascii_case("reset")
            || trimmed.eq_ignore_ascii_case("auto")
        {
            let image_generation = edgecrab_core::config::ImageGenerationConfig::default();
            let agent_clone = Arc::clone(&agent);
            self.rt_handle.block_on(async move {
                agent_clone
                    .set_image_generation_config(image_generation.clone())
                    .await;
            });
            match persist_image_generation_to_config(
                &edgecrab_core::config::ImageGenerationConfig::default(),
            ) {
                Ok(()) => self.push_output(
                    format!(
                        "Image generation reset to default: {}",
                        cli_image_models::default_selection_spec()
                    ),
                    OutputRole::System,
                ),
                Err(err) => self.push_output(
                    format!("Image model updated for this session, but config save failed: {err}"),
                    OutputRole::Error,
                ),
            }
            return;
        }

        let Some((provider, model)) = cli_image_models::parse_selection_spec(trimmed) else {
            self.push_output(
                format!(
                    "Invalid format: use 'provider/model-name', 'list', or 'default'. Got: '{trimmed}'"
                ),
                OutputRole::Error,
            );
            return;
        };

        let image_generation = edgecrab_core::config::ImageGenerationConfig {
            provider: provider.clone(),
            model: model.clone(),
        };
        let agent_clone = Arc::clone(&agent);
        self.rt_handle.block_on(async move {
            agent_clone
                .set_image_generation_config(image_generation.clone())
                .await;
        });
        match persist_image_generation_to_config(&edgecrab_core::config::ImageGenerationConfig {
            provider: provider.clone(),
            model: model.clone(),
        }) {
            Ok(()) => self.push_output(
                format!(
                    "Default image model set to {provider}/{model}. Future generate_image calls will use it unless the tool call overrides provider/model."
                ),
                OutputRole::System,
            ),
            Err(err) => self.push_output(
                format!("Image model updated for this session, but config save failed: {err}"),
                OutputRole::Error,
            ),
        }
    }

    fn open_image_model_selector(&mut self) {
        let current_spec = self
            .agent
            .as_ref()
            .map(|agent| {
                let (_, _, image_generation) = self.rt_handle.block_on(agent.media_config());
                format!(
                    "{}/{}",
                    image_generation.provider.trim(),
                    image_generation.model.trim()
                )
            })
            .filter(|spec| spec != "/")
            .unwrap_or_else(cli_image_models::default_selection_spec);
        let default_spec = cli_image_models::default_selection_spec();

        let mut entries = vec![ModelEntry {
            display: "default".to_string(),
            provider: "policy".to_string(),
            model_name: "default".to_string(),
            detail: format!("Reset to the built-in default image backend ({default_spec})"),
        }];
        entries.extend(
            cli_image_models::available_image_model_options()
                .into_iter()
                .map(|option| {
                    let mut detail = option.detail;
                    if option.selection_spec == default_spec {
                        detail.push_str(" | built-in default");
                    }
                    ModelEntry {
                        display: option.selection_spec,
                        provider: option.provider,
                        model_name: option.model,
                        detail,
                    }
                }),
        );

        self.image_model_selector.set_items(entries);
        self.reset_split_detail_scroll(DetailSurface::ImageModelSelector);
        self.image_model_selector
            .activate_with_primary(&current_spec);
    }

    fn handle_show_status(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let snap = self.agent_snapshot(&agent);
        let auxiliary = self.rt_handle.block_on(agent.auxiliary_config());
        let smart_routing = self.rt_handle.block_on(agent.smart_routing_config());
        let (_, _, image_generation) = self.rt_handle.block_on(agent.media_config());
        let vision_routing = match (auxiliary.provider.as_deref(), auxiliary.model.as_deref()) {
            (Some(provider), Some(model)) => format!("{provider}/{model}"),
            _ => "auto".to_string(),
        };
        let text = format!(
            "Session status:\n\
             Session ID:  {}\n\
             Model:       {}\n\
             Cheap:       {}\n\
             Vision:      {}\n\
             Image:       {}/{}\n\
             Messages:    {}\n\
             User turns:  {}\n\
             API calls:   {}\n\
             Budget:      {}/{} iterations remaining",
            snap.session_id.as_deref().unwrap_or("(none)"),
            snap.model,
            if smart_routing.enabled && !smart_routing.cheap_model.trim().is_empty() {
                smart_routing.cheap_model
            } else {
                "off".to_string()
            },
            vision_routing,
            image_generation.provider,
            image_generation.model,
            snap.message_count,
            snap.user_turn_count,
            snap.api_call_count,
            snap.budget_remaining,
            snap.budget_max,
        );
        self.open_report_overlay(
            "Session Status",
            "Live runtime model, routing, and budget snapshot",
            text,
        );
    }

    fn handle_show_cost(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let snap = self.agent_snapshot(&agent);
        let usage = edgecrab_core::CanonicalUsage {
            input_tokens: snap.input_tokens,
            output_tokens: snap.output_tokens,
            cache_read_tokens: snap.cache_read_tokens,
            cache_write_tokens: snap.cache_write_tokens,
            reasoning_tokens: snap.reasoning_tokens,
        };
        let cost_result = edgecrab_core::estimate_cost(&usage, &snap.model);
        let cost_line = match cost_result.amount_usd {
            Some(usd) => format!("${:.6} ({})", usd, cost_result.label),
            None => cost_result.label.clone(),
        };
        let text = format!(
            "Token usage & cost:\n\
             Current prompt:      {}\n\
             Input tokens:       {}\n\
             Output tokens:      {}\n\
             Cache read tokens:  {}\n\
             Cache write tokens: {}\n\
             Reasoning tokens:   {}\n\
             Total tokens:       {}\n\
             API calls:          {}\n\
             \n\
             Estimated cost: {}",
            snap.context_pressure_tokens(),
            snap.input_tokens,
            snap.output_tokens,
            snap.cache_read_tokens,
            snap.cache_write_tokens,
            snap.reasoning_tokens,
            snap.total_tokens(),
            snap.api_call_count,
            cost_line,
        );
        self.open_report_overlay("Token Usage & Cost", format!("Model {}", snap.model), text);
        self.total_tokens = snap.context_pressure_tokens();
        if let Some(usd) = cost_result.amount_usd {
            self.session_cost = usd;
        }
    }

    fn handle_show_usage(&mut self) {
        self.handle_show_cost();
    }

    fn handle_show_prompt(&mut self) {
        let prompt = self
            .agent
            .clone()
            .and_then(|agent| {
                self.rt_handle
                    .block_on(async move { agent.custom_system_prompt().await })
            })
            .or_else(|| {
                let configured = self.load_runtime_config().agent.system_prompt;
                (!configured.trim().is_empty()).then_some(configured)
            });

        match prompt {
            Some(prompt) => self.open_report_overlay(
                "System Prompt",
                format!("Custom override ({} characters)", prompt.len()),
                prompt,
            ),
            None => self.open_report_overlay(
                "System Prompt",
                "Using the default assembled prompt",
                "No custom prompt set.\n\nUsage:\n  /prompt clear\n  /prompt <text>\n\nEdgeCrab will keep using the default assembled system prompt until you set an override.",
            ),
        }
    }

    fn handle_prompt_command(&mut self, raw: String) {
        let trimmed = raw.trim();
        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("show") {
            self.handle_show_prompt();
            return;
        }

        let cleared = trimmed.eq_ignore_ascii_case("clear");
        let mut config = self.load_runtime_config();
        config.agent.system_prompt = if cleared {
            String::new()
        } else {
            trimmed.to_string()
        };

        let config_path = edgecrab_core::edgecrab_home().join("config.yaml");
        let save_result = config.save_to(&config_path);
        if let Some(agent) = self.agent.clone() {
            let next_prompt = (!cleared).then_some(trimmed.to_string());
            self.rt_handle
                .block_on(async move { agent.set_custom_system_prompt(next_prompt).await });
        }

        match (cleared, save_result) {
            (true, Ok(())) => self.push_output(
                "System prompt cleared (saved to config).",
                OutputRole::System,
            ),
            (true, Err(err)) => self.push_output(
                format!("System prompt cleared for this session. Saving config failed: {err}"),
                OutputRole::System,
            ),
            (false, Ok(())) => self.push_output(
                format!(
                    "System prompt set (saved to config).\n\"{}\"",
                    edgecrab_core::safe_truncate(trimmed, 80)
                ),
                OutputRole::System,
            ),
            (false, Err(err)) => self.push_output(
                format!(
                    "System prompt set for this session. Saving config failed: {err}\n\"{}\"",
                    edgecrab_core::safe_truncate(trimmed, 80)
                ),
                OutputRole::System,
            ),
        }
    }

    fn render_config_summary(&self) -> String {
        let config = self.load_runtime_config();
        let worktree_status = self.current_worktree_status();
        let voice_mode = if self.voice_mode_enabled { "on" } else { "off" };
        let session_personality = self
            .session_personality
            .as_deref()
            .unwrap_or("(config default)");
        let session_skin = self.session_skin.as_deref().unwrap_or("(config default)");
        let model = if self.model_name == "none" {
            config.model.default_model.clone()
        } else {
            self.model_name.clone()
        };
        format!(
            "EdgeCrab config summary:\n\
             Model:           {}\n\
             Cheap model:     {}\n\
             MoA:             {}\n\
             MoA aggregator:  {}\n\
             MoA refs:        {}\n\
             Reasoning pane:  {}\n\
             Streaming:       {}\n\
             Tool progress:   {}\n\
             Status bar:      {}\n\
             Voice readback:  {}\n\
             Worktree mode:   {} ({})\n\
             Personality:     {} (session: {})\n\
             Skin:            {} (session: {})\n\
             Toolsets:        {}\n\
             Gateway host:    {}:{}\n\
             MCP servers:     {}\n\
             Skills preload:  {}\n\
            \n{}",
            model,
            if config.model.smart_routing.enabled
                && !config.model.smart_routing.cheap_model.is_empty()
            {
                config.model.smart_routing.cheap_model.clone()
            } else {
                "off".into()
            },
            if config.moa.enabled { "on" } else { "off" },
            config.moa.aggregator_model,
            config.moa.reference_models.len(),
            if self.show_reasoning {
                "shown"
            } else {
                "hidden"
            },
            if self.streaming_enabled { "on" } else { "off" },
            self.tool_progress_mode.label(),
            if self.show_status_bar {
                "visible"
            } else {
                "hidden"
            },
            voice_mode,
            if config.worktree { "on" } else { "off" },
            worktree_status.current_checkout_label(),
            config.display.personality,
            session_personality,
            config.display.skin,
            session_skin,
            config
                .tools
                .enabled_toolsets
                .as_ref()
                .filter(|sets| !sets.is_empty())
                .map(|sets| sets.join(", "))
                .unwrap_or_else(|| "(default)".into()),
            config.gateway.host,
            config.gateway.port,
            config.mcp_servers.len(),
            if config.skills.preloaded.is_empty() {
                "(none)".into()
            } else {
                config.skills.preloaded.join(", ")
            },
            self.render_gateway_home_channel_summary(&config),
        )
    }

    fn render_config_paths(&self) -> String {
        let home = edgecrab_core::edgecrab_home();
        format!(
            "EdgeCrab paths:\n\
             Config:      {}\n\
             Env:         {}\n\
             Memories:    {}\n\
             Skills:      {}\n\
             Sessions DB: {}\n\
             Skins:       {}\n\
             MCP tokens:  {}\n\
             Images:      {}\n\
             \nUse `edgecrab config edit` for editor-based changes or `/config` for the in-TUI control center.",
            home.join("config.yaml").display(),
            home.join(".env").display(),
            home.join("memories").display(),
            home.join("skills").display(),
            home.join("sessions.db").display(),
            home.join("skin.yaml").display(),
            home.join("mcp-tokens").display(),
            home.join("images").display(),
        )
    }

    fn render_gateway_home_channel_summary(&self, config: &edgecrab_core::AppConfig) -> String {
        crate::gateway_presentation::render_gateway_home_channel_summary(config)
    }

    fn build_config_entries(&self) -> Vec<ConfigEntry> {
        let config = self.load_runtime_config();
        vec![
            ConfigEntry {
                title: "Session Summary".into(),
                detail: "Current model, display state, toolsets, gateway host, skills preload"
                    .into(),
                tag: "overview".into(),
                action: ConfigAction::ShowSummary,
            },
            ConfigEntry {
                title: "Paths".into(),
                detail: "Config, env, memories, skills, sessions DB, skin and image directories"
                    .into(),
                tag: "files".into(),
                action: ConfigAction::ShowPaths,
            },
            ConfigEntry {
                title: format!(
                    "Worktree Mode  [{}]",
                    if config.worktree { "on" } else { "off" }
                ),
                detail:
                    "Inspect the current checkout and change the saved default for future launches"
                        .into(),
                tag: "git".into(),
                action: ConfigAction::ShowWorktree,
            },
            ConfigEntry {
                title: "Tools".into(),
                detail:
                    "Browse toolsets and individual tools with live checkboxes and reset support"
                        .into(),
                tag: "tools".into(),
                action: ConfigAction::OpenTools,
            },
            ConfigEntry {
                title: format!("Model  [{}]", self.model_name),
                detail: "Open the live model selector".into(),
                tag: "model".into(),
                action: ConfigAction::OpenModel,
            },
            ConfigEntry {
                title: format!(
                    "Cheap Model  [{}]",
                    if config.model.smart_routing.enabled
                        && !config.model.smart_routing.cheap_model.trim().is_empty()
                    {
                        config.model.smart_routing.cheap_model.as_str()
                    } else {
                        "off"
                    }
                ),
                detail: "Pick the model used for obviously simple turns".into(),
                tag: "model".into(),
                action: ConfigAction::OpenCheapModel,
            },
            ConfigEntry {
                title: format!("MoA  [{}]", if config.moa.enabled { "on" } else { "off" }),
                detail: "Enable or disable the moa tool while preserving its saved defaults".into(),
                tag: "moa".into(),
                action: ConfigAction::ToggleMoa,
            },
            ConfigEntry {
                title: "Vision Backend".into(),
                detail: "Inspect or switch the dedicated vision routing".into(),
                tag: "model".into(),
                action: ConfigAction::OpenVisionModel,
            },
            ConfigEntry {
                title: "Image Backend".into(),
                detail: "Inspect or switch the default image generation backend".into(),
                tag: "model".into(),
                action: ConfigAction::OpenImageModel,
            },
            ConfigEntry {
                title: format!(
                    "MoA Aggregator  [{}]",
                    if config.moa.enabled {
                        config.moa.aggregator_model.as_str()
                    } else {
                        "disabled"
                    }
                ),
                detail: "Choose the synthesizer model used by the moa tool when it is enabled"
                    .into(),
                tag: "moa".into(),
                action: ConfigAction::OpenMoaAggregator,
            },
            ConfigEntry {
                title: format!(
                    "MoA References  [{} models]",
                    config.moa.reference_models.len()
                ),
                detail: "Edit the full default expert roster used by the moa tool".into(),
                tag: "moa".into(),
                action: ConfigAction::OpenMoaReferences,
            },
            ConfigEntry {
                title: "MoA Add Expert".into(),
                detail: "Add one expert to the default MoA roster from the model catalog".into(),
                tag: "moa".into(),
                action: ConfigAction::AddMoaExpert,
            },
            ConfigEntry {
                title: "MoA Remove Expert".into(),
                detail: "Remove one configured expert from the default MoA roster".into(),
                tag: "moa".into(),
                action: ConfigAction::RemoveMoaExpert,
            },
            ConfigEntry {
                title: format!(
                    "Streaming  [{}]",
                    if self.streaming_enabled { "on" } else { "off" }
                ),
                detail: "Toggle token-by-token rendering for future turns".into(),
                tag: "display".into(),
                action: ConfigAction::ToggleStreaming,
            },
            ConfigEntry {
                title: format!(
                    "Reasoning Pane  [{}]",
                    if self.show_reasoning {
                        "shown"
                    } else {
                        "hidden"
                    }
                ),
                detail: "Toggle visible reasoning blocks in the transcript".into(),
                tag: "display".into(),
                action: ConfigAction::ToggleReasoning,
            },
            ConfigEntry {
                title: format!(
                    "Status Bar  [{}]",
                    if self.show_status_bar {
                        "visible"
                    } else {
                        "hidden"
                    }
                ),
                detail: "Toggle the live status strip below the transcript".into(),
                tag: "display".into(),
                action: ConfigAction::ToggleStatusBar,
            },
            ConfigEntry {
                title: format!(
                    "Logs  [{}]",
                    effective_log_level(&edgecrab_core::edgecrab_home(), false).as_str()
                ),
                detail: "Browse log files and save the default log verbosity".into(),
                tag: "ops".into(),
                action: ConfigAction::OpenLogs,
            },
            ConfigEntry {
                title: format!("Skin  [{}]", config.display.skin),
                detail: "Browse installed skins and apply one without leaving the TUI".into(),
                tag: "display".into(),
                action: ConfigAction::OpenSkins,
            },
            ConfigEntry {
                title: format!(
                    "Voice  [{}]",
                    if self.voice_mode_enabled {
                        "readback on"
                    } else {
                        "readback off"
                    }
                ),
                detail: "Show voice/TTS status and configuration".into(),
                tag: "voice".into(),
                action: ConfigAction::ShowVoice,
            },
            ConfigEntry {
                title: "Gateway Platforms".into(),
                detail: "Browse platform state, delivery semantics, and inline gateway controls"
                    .into(),
                tag: "gateway".into(),
                action: ConfigAction::OpenGatewayBrowser,
            },
            ConfigEntry {
                title: "Gateway Home Channels".into(),
                detail: "Review or edit Telegram, Discord, and Slack home-channel routing".into(),
                tag: "gateway".into(),
                action: ConfigAction::ShowGatewayHomes,
            },
            ConfigEntry {
                title: "Update Status".into(),
                detail: "Check git-based upgrade status and show actionable local guidance".into(),
                tag: "ops".into(),
                action: ConfigAction::ShowUpdateStatus,
            },
        ]
    }

    fn open_config_selector(&mut self) {
        self.config_selector.set_items(self.build_config_entries());
        self.reset_split_detail_scroll(DetailSurface::ConfigSelector);
        self.config_selector.activate();
        self.needs_redraw = true;
    }

    fn handle_config_selector_action(&mut self, action: ConfigAction) {
        match action {
            ConfigAction::ShowSummary => {
                self.open_report_overlay(
                    "Config Summary",
                    "Runtime defaults, display state, and saved control-plane settings",
                    self.render_config_summary(),
                );
            }
            ConfigAction::ShowPaths => {
                self.open_report_overlay(
                    "Config Paths",
                    "Resolved EdgeCrab config, data, and cache locations",
                    self.render_config_paths(),
                );
            }
            ConfigAction::ShowWorktree => {
                self.handle_worktree_command(String::new());
            }
            ConfigAction::OpenTools => {
                self.open_tool_manager(ToolManagerMode::All);
            }
            ConfigAction::OpenModel => {
                self.refresh_model_selector_catalog();
            }
            ConfigAction::OpenCheapModel => {
                self.open_cheap_model_selector();
            }
            ConfigAction::ToggleMoa => {
                let enabled = self
                    .agent
                    .as_ref()
                    .map(|agent| self.rt_handle.block_on(agent.moa_config()).enabled)
                    .unwrap_or_else(|| self.load_runtime_config().moa.enabled);
                self.handle_set_moa_enabled(!enabled);
            }
            ConfigAction::OpenVisionModel => {
                self.open_vision_model_selector();
            }
            ConfigAction::OpenImageModel => {
                self.open_image_model_selector();
            }
            ConfigAction::OpenMoaAggregator => {
                self.open_moa_aggregator_selector();
            }
            ConfigAction::OpenMoaReferences => {
                self.open_moa_reference_selector();
            }
            ConfigAction::AddMoaExpert => {
                self.open_moa_add_expert_selector();
            }
            ConfigAction::RemoveMoaExpert => {
                self.open_moa_remove_expert_selector();
            }
            ConfigAction::ToggleStreaming => {
                self.handle_set_streaming("toggle".into());
            }
            ConfigAction::ToggleReasoning => {
                let next = if self.show_reasoning { "hide" } else { "show" };
                self.handle_set_reasoning(next.into());
            }
            ConfigAction::ToggleStatusBar => {
                self.handle_status_bar_command("toggle".into());
            }
            ConfigAction::OpenLogs => {
                self.handle_log_command(String::new());
            }
            ConfigAction::OpenSkins => {
                self.open_skin_browser();
            }
            ConfigAction::ShowVoice => {
                self.handle_voice_mode("status".into());
            }
            ConfigAction::OpenGatewayBrowser => {
                self.open_gateway_browser();
            }
            ConfigAction::ShowGatewayHomes => {
                let config = self.load_runtime_config();
                self.open_report_overlay(
                    "Gateway Home Channels",
                    "Per-platform DM and home-channel routing",
                    self.render_gateway_home_channel_summary(&config),
                );
            }
            ConfigAction::ShowUpdateStatus => {
                self.handle_update_status();
            }
        }
    }

    fn handle_config_command(&mut self, args: String) {
        let normalized = args.trim().to_ascii_lowercase();
        match normalized.as_str() {
            "" | "open" | "browse" => self.open_config_selector(),
            "show" | "summary" | "status" => {
                self.open_report_overlay(
                    "Config Summary",
                    "Runtime defaults, display state, and saved control-plane settings",
                    self.render_config_summary(),
                );
            }
            "model" => self.refresh_model_selector_catalog(),
            "cheap" | "cheap_model" | "cheap-model" | "routing" => {
                self.open_cheap_model_selector();
            }
            "moa" => self.handle_show_moa_config(),
            "tools" | "toolsets" => self.open_tool_manager(ToolManagerMode::All),
            "log" | "logs" => self.handle_log_command(String::new()),
            "paths" | "path" => {
                self.open_report_overlay(
                    "Config Paths",
                    "Resolved EdgeCrab config, data, and cache locations",
                    self.render_config_paths(),
                );
            }
            "voice" => self.handle_voice_mode("status".into()),
            "gateway" | "homes" => {
                self.open_gateway_browser();
            }
            "update" => self.handle_update_status(),
            "edit" => {
                self.open_report_overlay(
                    "Config Edit",
                    "Editor-based configuration workflow",
                    format!(
                        "Edit the config file with your editor of choice:\n{}\n\nCLI shortcut: edgecrab config edit",
                        edgecrab_core::edgecrab_home().join("config.yaml").display()
                    ),
                );
            }
            _ => self.push_output(
                "Usage: /config [open|show|model|cheap|moa|logs|paths|voice|gateway|update|edit]",
                OutputRole::System,
            ),
        }
    }

    fn current_worktree_status(&self) -> crate::worktree::WorktreeRuntimeStatus {
        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        crate::worktree::inspect_runtime(&cwd, self.load_runtime_config().worktree)
    }

    fn render_worktree_report(&self) -> String {
        self.current_worktree_status().render_report()
    }

    fn open_worktree_report(&mut self, body: String) {
        self.open_report_overlay(
            "Git Worktree Mode",
            "Current checkout plus the saved default launch policy",
            body,
        );
    }

    fn handle_set_worktree_mode(&mut self, enabled: bool) {
        match persist_worktree_enabled_to_config(enabled) {
            Ok(()) => {
                self.config_selector.set_items(self.build_config_entries());
                let mut report = self.render_worktree_report();
                report.push_str(&format!(
                    "\nSaved default updated: isolated worktrees are {} for future launches.\n",
                    if enabled { "enabled" } else { "disabled" }
                ));
                self.open_worktree_report(report);
            }
            Err(error) => self.push_output(
                format!("Failed to update worktree default: {error}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_worktree_command(&mut self, args: String) {
        let normalized = args.trim().to_ascii_lowercase();
        match normalized.as_str() {
            "" | "status" | "show" => self.open_worktree_report(self.render_worktree_report()),
            "on" | "enable" | "enabled" => self.handle_set_worktree_mode(true),
            "off" | "disable" | "disabled" => self.handle_set_worktree_mode(false),
            "toggle" => self.handle_set_worktree_mode(!self.load_runtime_config().worktree),
            _ => self.push_output(
                "Usage: /worktree [status|on|off|toggle]",
                OutputRole::System,
            ),
        }
    }

    fn build_log_browser_entry(&self, info: LogFileInfo) -> LogBrowserEntry {
        let kind = log_kind_for_name(&info.name).to_string();
        let size_label = format_log_size(info.size_bytes);
        let modified_label = format_log_timestamp_label(info.modified);
        let preview =
            tail_preview(&info.path, 8).unwrap_or_else(|error| format!("tail failed: {error}"));
        let preview_excerpt = preview
            .lines()
            .rev()
            .find(|line| !line.trim().is_empty())
            .map(|line| unicode_trunc(line.trim(), 92))
            .unwrap_or_else(|| "(empty log)".into());
        let saved_level = effective_log_level(&edgecrab_core::edgecrab_home(), false);
        let detail = format!("{kind} | {size_label} | modified {modified_label}");
        let detail_view = format!(
            "File:      {}\nPath:      {}\nKind:      {}\nSize:      {}\nModified:  {}\nLevel:     {} (saved default)\n\nRecent tail:\n{}\n\nActions: Enter inspect | R reload | 1-5 level | Esc close",
            info.name,
            info.path.display(),
            kind,
            size_label,
            modified_label,
            saved_level.badge(),
            preview
        );
        let search_text = format!(
            "{} {} {} {} {}",
            info.name, kind, size_label, modified_label, preview
        );

        LogBrowserEntry {
            name: info.name,
            path: info.path,
            kind,
            size_label,
            modified_label,
            preview: preview_excerpt,
            detail,
            detail_view,
            search_text,
        }
    }

    fn refresh_log_browser(&mut self) {
        let home = edgecrab_core::edgecrab_home();
        match list_log_files(&home) {
            Ok(files) => {
                let status_note = if files.is_empty() {
                    Some(format!(
                        "No log files yet under {}. {}.",
                        home.join("logs").display(),
                        self.log_follow.badge()
                    ))
                } else {
                    Some(format!(
                        "Saved level {}. {}. F toggle  R reload  1 error  2 warn  3 info  4 debug  5 trace.",
                        effective_log_level(&home, false).badge(),
                        self.log_follow.badge()
                    ))
                };
                let entries = files
                    .into_iter()
                    .map(|info| self.build_log_browser_entry(info))
                    .collect::<Vec<_>>();
                self.log_browser.replace_items_preserving_state(entries);
                self.log_browser_status_note = status_note;
                self.log_follow.touch(Instant::now());
                self.needs_redraw = true;
            }
            Err(error) => {
                self.log_browser.set_items(Vec::new());
                self.log_browser_status_note = Some(format!("Failed to load logs: {error}"));
                self.push_output(format!("log: {error}"), OutputRole::Error);
            }
        }
    }

    fn open_log_browser(&mut self, initial_query: Option<&str>) {
        self.refresh_log_browser();
        self.log_browser.active = true;
        self.log_browser.query = initial_query.unwrap_or_default().trim().to_string();
        self.log_browser.selected = 0;
        self.log_browser.update_filter();
        self.log_browser_pane.reset();
        self.reset_detail_fullscreen_scroll(DetailSurface::LogBrowser);
        self.needs_redraw = true;
    }

    fn refresh_log_inspector(&mut self) {
        let Some(mut file) = self.log_inspector.file.clone() else {
            return;
        };

        let selected_signature = self.log_inspector.selector.current().map(|entry| {
            (
                entry.timestamp.clone(),
                entry.level_label.clone(),
                entry.detail.clone(),
            )
        });
        let query = self.log_inspector.selector.query.clone();
        let preserve_scroll = !self.log_follow.enabled
            || !query.trim().is_empty()
            || self.log_inspector.selector.selected.saturating_add(1)
                < self.log_inspector.selector.filtered.len();
        let prior_focus = self.log_inspector.pane.focus;
        let prior_scroll = self.log_inspector.pane.scroll;
        let prior_fullscreen_scroll = self.detail_fullscreen_scroll(DetailSurface::LogInspector);

        let content = read_last_lines(&file.path, default_tail_lines())
            .unwrap_or_else(|error| format!("failed to read {}: {error}", file.path.display()));
        file.preview =
            tail_preview(&file.path, 1).unwrap_or_else(|error| format!("tail failed: {error}"));
        if let Ok(metadata) = std::fs::metadata(&file.path) {
            file.size_label = format_log_size(metadata.len());
            file.modified_label = format_log_timestamp_label(metadata.modified().ok());
        }
        let items = build_log_line_entries(&content);
        self.log_inspector.selector.set_items(items);
        self.log_inspector.file = Some(file);
        self.log_inspector.selector.query = query;
        self.log_inspector.selector.update_filter();
        self.log_inspector.pane.focus = prior_focus;

        if preserve_scroll {
            if let Some((timestamp, level_label, detail)) = selected_signature
                && let Some(position) =
                    self.log_inspector
                        .selector
                        .filtered
                        .iter()
                        .position(|&idx| {
                            self.log_inspector
                                .selector
                                .items
                                .get(idx)
                                .is_some_and(|entry| {
                                    entry.timestamp == timestamp
                                        && entry.level_label == level_label
                                        && entry.detail == detail
                                })
                        })
            {
                self.log_inspector.selector.selected = position;
            }
            self.log_inspector.pane.scroll = prior_scroll;
            if self.detail_fullscreen_active(DetailSurface::LogInspector)
                && let Some(state) = self.detail_fullscreen.as_mut()
                && state.surface == DetailSurface::LogInspector
            {
                state.scroll = prior_fullscreen_scroll;
            }
        } else {
            self.log_inspector.selector.selected =
                self.log_inspector.selector.filtered.len().saturating_sub(1);
            self.log_inspector.pane.reset_scroll();
            self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
        }

        self.log_follow.touch(Instant::now());
        self.needs_redraw = true;
    }

    fn build_log_inspector_meta(&self, entry: &LogBrowserEntry) -> LogInspectorMeta {
        LogInspectorMeta {
            name: entry.name.clone(),
            path: entry.path.clone(),
            kind: entry.kind.clone(),
            size_label: entry.size_label.clone(),
            modified_label: entry.modified_label.clone(),
            preview: entry.preview.clone(),
        }
    }

    fn open_log_inspector(&mut self, entry: LogBrowserEntry) {
        self.log_inspector.file = Some(self.build_log_inspector_meta(&entry));
        self.log_inspector.selector.active = true;
        self.log_inspector.selector.selected = 0;
        self.log_inspector.selector.query.clear();
        self.log_inspector.pane.reset();
        self.log_inspector.return_to_browser = true;
        self.log_browser.active = false;
        self.close_detail_fullscreen(DetailSurface::LogBrowser);
        self.reset_detail_fullscreen_scroll(DetailSurface::LogInspector);
        self.refresh_log_inspector();
        self.needs_redraw = true;
    }

    fn close_log_inspector(&mut self) {
        let return_to_browser = self.log_inspector.return_to_browser;
        self.log_inspector.close();
        self.close_detail_fullscreen(DetailSurface::LogInspector);
        self.log_browser.active = return_to_browser;
        self.needs_redraw = true;
    }

    fn toggle_log_follow(&mut self) {
        self.log_follow.enabled = !self.log_follow.enabled;
        self.log_follow.touch(Instant::now());
        if self.log_follow.enabled {
            if self.log_inspector.active() {
                self.refresh_log_inspector();
            } else if self.log_browser.active {
                self.refresh_log_browser();
            }
        } else {
            self.needs_redraw = true;
        }
    }

    fn refresh_log_follow_if_due_at(&mut self, now: Instant) {
        if !self.log_follow.due(now) {
            return;
        }
        if self.log_inspector.active() {
            self.refresh_log_inspector();
        } else if self.log_browser.active {
            self.refresh_log_browser();
        }
        self.log_follow.touch(now);
    }

    fn apply_log_level(&mut self, level: LogLevelSetting) {
        let home = edgecrab_core::edgecrab_home();
        match persist_log_level(&home, level) {
            Ok(()) => {
                let applied_live = reload_runtime_log_level(level).unwrap_or(false);
                self.refresh_log_browser();
                self.config_selector.set_items(self.build_config_entries());
                let mut message = format!(
                    "Saved default log level: {}.",
                    level.as_str().to_ascii_uppercase()
                );
                if applied_live {
                    message.push_str(" Applied to the current process.");
                } else {
                    message.push_str(" Future launches will use it.");
                }
                self.push_output(message, OutputRole::System);
            }
            Err(error) => self.push_output(
                format!("Failed to update log level: {error}"),
                OutputRole::Error,
            ),
        }
    }

    fn parse_log_level_command(&self, args: &str) -> Option<LogLevelSetting> {
        let trimmed = args.trim();
        if let Some(rest) = trimmed.strip_prefix("level ") {
            return LogLevelSetting::parse(rest);
        }
        LogLevelSetting::parse(trimmed)
    }

    fn handle_log_command(&mut self, args: String) {
        let trimmed = args.trim();
        let normalized = trimmed.to_ascii_lowercase();
        if normalized.is_empty() || matches!(normalized.as_str(), "open" | "browse" | "show") {
            self.open_log_browser(None);
            return;
        }
        if matches!(normalized.as_str(), "reload" | "refresh") {
            self.refresh_log_browser();
            self.open_log_browser(None);
            return;
        }
        if matches!(normalized.as_str(), "status" | "files") {
            self.open_log_browser(None);
            return;
        }
        if let Some(level) = self.parse_log_level_command(&normalized) {
            self.apply_log_level(level);
            if self.log_browser.active {
                self.refresh_log_browser();
            }
            return;
        }

        self.push_output(
            "Usage: /log [open|reload|level <error|warn|info|debug|trace>]",
            OutputRole::System,
        );
    }

    fn handle_show_history(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let snap = self.agent_snapshot(&agent);
        let text = format!(
            "Session history:\n\
             Messages:   {}\n\
             User turns: {}\n\
             API calls:  {}\n\
             Tokens:     {} in / {} out\n\
             \nUse /export to save the full conversation as Markdown.",
            snap.message_count,
            snap.user_turn_count,
            snap.api_call_count,
            snap.input_tokens,
            snap.output_tokens,
        );
        self.open_report_overlay(
            "Session History",
            "Current live conversation counters",
            text,
        );
    }

    fn handle_save_session(&mut self, path: Option<String>) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let messages = self
            .rt_handle
            .block_on(async move { agent.messages().await });
        if messages.is_empty() {
            self.push_output("No messages to save.", OutputRole::System);
            return;
        }
        let path = path.unwrap_or_else(|| {
            let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
            format!("edgecrab-session-{ts}.json")
        });
        let expanded = shellexpand::tilde(&path).to_string();
        match serde_json::to_string_pretty(&messages) {
            Ok(json) => match std::fs::write(&expanded, &json) {
                Ok(()) => {
                    self.push_output(
                        format!(
                            "Session saved to {} ({} messages, {} bytes)",
                            expanded,
                            messages.len(),
                            json.len()
                        ),
                        OutputRole::System,
                    );
                }
                Err(e) => self.push_output(
                    format!("Failed to write {expanded}: {e}"),
                    OutputRole::Error,
                ),
            },
            Err(e) => self.push_output(format!("Serialization error: {e}"), OutputRole::Error),
        }
    }

    fn handle_export_session(&mut self, path: Option<String>) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let (messages, snap) = self.rt_handle.block_on(async {
            let msgs = agent.messages().await;
            let s = agent.session_snapshot().await;
            (msgs, s)
        });
        if messages.is_empty() {
            self.push_output("No messages to export.", OutputRole::System);
            return;
        }
        let path = path.unwrap_or_else(|| {
            let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
            format!("edgecrab-conversation-{ts}.md")
        });
        let expanded = shellexpand::tilde(&path).to_string();
        let mut md = format!(
            "# EdgeCrab Conversation\n\nModel: {}\n\n---\n\n",
            snap.model
        );
        for msg in &messages {
            let role = msg.role.as_str();
            let content = msg.text_content();
            md.push_str(&format!("## {}\n\n{}\n\n", role, content));
        }
        match std::fs::write(&expanded, &md) {
            Ok(()) => self.push_output(
                format!("Exported to {} ({} messages)", expanded, messages.len()),
                OutputRole::System,
            ),
            Err(e) => self.push_output(
                format!("Failed to write {expanded}: {e}"),
                OutputRole::Error,
            ),
        }
    }

    fn handle_set_title(&mut self, title: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let t = title.clone();
        self.rt_handle.block_on(async move {
            agent.set_session_title(t).await;
        });
        self.push_output(format!("Session title set to: {title}"), OutputRole::System);
    }

    /// Open the session browser overlay: load up to 50 sessions from the DB,
    /// convert them to `SessionBrowserEntry`, activate the `FuzzySelector`.
    fn open_skin_browser(&mut self) {
        let engine = crate::skin_engine::SkinEngine::new();
        let current = self
            .session_skin
            .clone()
            .unwrap_or_else(|| "default".into());
        let entries: Vec<SkinEntry> = engine
            .list_skins()
            .into_iter()
            .map(|name| {
                let is_active = name == current;
                let desc = if is_active {
                    "active".to_string()
                } else {
                    String::new()
                };
                SkinEntry {
                    name,
                    desc,
                    is_active,
                }
            })
            .collect();
        self.skin_browser.set_items(entries);
        // Pre-select the currently active skin
        self.skin_browser.activate_with_primary(current.as_str());
        self.needs_redraw = true;
    }

    fn require_session_db(&mut self) -> Option<Arc<edgecrab_state::SessionDb>> {
        let agent = self.require_agent()?;
        if !agent.has_state_db() {
            self.push_output(
                "No state database configured (run with --session to enable)",
                OutputRole::System,
            );
            return None;
        }
        match agent.state_db_handle() {
            Some(db) => Some(db),
            None => {
                self.push_output("No state database configured.", OutputRole::System);
                None
            }
        }
    }

    fn load_session_browser_entries(
        db: &edgecrab_state::SessionDb,
        query: &str,
    ) -> Result<(Vec<SessionBrowserEntry>, Option<String>), edgecrab_types::AgentError> {
        let recent = db.list_sessions_rich(None, SESSION_BROWSER_LIMIT)?;
        let trimmed = query.trim();
        if trimmed.is_empty() {
            let entries = recent
                .iter()
                .map(SessionBrowserEntry::from_summary)
                .collect::<Vec<_>>();
            return Ok((
                entries,
                Some("Recent sessions. Type to search metadata or full message history.".into()),
            ));
        }

        let hits = db.search_sessions_rich(trimmed, SESSION_BROWSER_SEARCH_LIMIT)?;
        let mut entries = Vec::new();
        let mut seen = HashSet::new();
        for hit in &hits {
            seen.insert(hit.session.id.clone());
            entries.push(SessionBrowserEntry::from_search_hit(hit));
        }
        for summary in &recent {
            if seen.insert(summary.id.clone()) {
                entries.push(SessionBrowserEntry::from_summary(summary));
            }
        }

        let status_note = if hits.is_empty() {
            Some("Searching recent metadata plus the indexed message history.".into())
        } else {
            Some(format!(
                "{} ranked content match(es) surfaced from the full session archive.",
                hits.len()
            ))
        };

        Ok((entries, status_note))
    }

    fn refresh_session_browser(&mut self) -> bool {
        let query = self.session_browser.query.clone();
        let Some(db) = self.require_session_db() else {
            return false;
        };
        match Self::load_session_browser_entries(db.as_ref(), &query) {
            Ok((entries, status_note)) => {
                self.session_browser.replace_items_preserving_state(entries);
                self.session_browser_status_note = status_note;
                self.needs_redraw = true;
                true
            }
            Err(e) => {
                self.push_output(format!("DB error: {e}"), OutputRole::Error);
                false
            }
        }
    }

    fn open_session_browser_with_query(&mut self, initial_query: Option<&str>) {
        self.session_inspector.close();
        self.close_detail_fullscreen(DetailSurface::SessionInspector);
        self.close_detail_fullscreen(DetailSurface::SessionBrowser);
        self.session_browser_pane.reset();
        self.session_browser.query = initial_query.unwrap_or_default().trim().to_string();
        self.session_browser.selected = 0;
        if !self.refresh_session_browser() {
            return;
        }
        if self.session_browser.items.is_empty() && self.session_browser.query.is_empty() {
            self.push_output("No saved sessions to browse.", OutputRole::System);
            return;
        }
        self.session_browser.active = true;
        self.needs_redraw = true;
    }

    fn open_session_browser(&mut self) {
        self.open_session_browser_with_query(None);
    }

    fn open_session_inspector(&mut self, entry: SessionBrowserEntry) {
        let Some(db) = self.require_session_db() else {
            return;
        };

        match db.get_messages(&entry.id) {
            Ok(messages) => {
                let items = messages
                    .iter()
                    .enumerate()
                    .map(|(index, message)| SessionMessageEntry::from_message(index, message))
                    .collect::<Vec<_>>();
                let selected = Self::best_session_message_selection(
                    &items,
                    entry.matched_role.as_deref(),
                    entry.matched_snippet.as_deref(),
                );
                self.session_inspector.selector.query.clear();
                self.session_inspector.selector.selected = selected;
                self.session_inspector.selector.set_items(items);
                self.session_inspector.session =
                    Some(SessionInspectorMeta::from_browser_entry(&entry));
                self.session_inspector.pane.reset();
                self.session_inspector.return_to_browser = true;
                self.session_inspector.selector.active = true;
                self.session_browser.active = false;
                self.close_detail_fullscreen(DetailSurface::SessionBrowser);
                self.close_detail_fullscreen(DetailSurface::SessionInspector);
                self.needs_redraw = true;
            }
            Err(e) => self.push_output(format!("DB error: {e}"), OutputRole::Error),
        }
    }

    fn open_current_session_inspector(&mut self) {
        let Some(agent) = self.require_agent() else {
            return;
        };

        let (snapshot, messages) = match tokio::runtime::Handle::try_current()
            .map(|handle| handle.runtime_flavor())
        {
            Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(|| {
                self.rt_handle.block_on(async {
                    let snapshot = agent.session_snapshot().await;
                    let messages = agent.messages().await;
                    (snapshot, messages)
                })
            }),
            Ok(_) => {
                let Some(snapshot) = agent.try_session_snapshot() else {
                    self.push_output(
                        "Current session is busy; try again after the current turn.",
                        OutputRole::System,
                    );
                    return;
                };
                let Some(messages) = agent.try_messages() else {
                    self.push_output(
                        "Current session is busy; try again after the current turn.",
                        OutputRole::System,
                    );
                    return;
                };
                (snapshot, messages)
            }
            Err(_) => self.rt_handle.block_on(async {
                let snapshot = agent.session_snapshot().await;
                let messages = agent.messages().await;
                (snapshot, messages)
            }),
        };
        let preview = messages
            .iter()
            .map(message_preview)
            .find(|text| !text.trim().is_empty())
            .unwrap_or_else(|| "No conversation content yet.".into());

        let mut meta = SessionInspectorMeta {
            id: snapshot
                .session_id
                .clone()
                .unwrap_or_else(|| "current-session".into()),
            title: "Current session".into(),
            source: "live".into(),
            model: snapshot.model.clone(),
            started_label: "active now".into(),
            last_active_label: "active now".into(),
            message_count: messages.len() as i64,
            preview,
            matched_role: None,
            matched_snippet: None,
            is_live: true,
            debug_lines: vec![
                format!(
                    "Live stats: {} user turn(s) · {} API call(s)",
                    snapshot.user_turn_count, snapshot.api_call_count
                ),
                format!(
                    "Tokens: prompt {} · output {} · reasoning {}",
                    snapshot.prompt_tokens(),
                    snapshot.output_tokens,
                    snapshot.reasoning_tokens
                ),
                format!(
                    "Budget: {} / {} remaining",
                    snapshot.budget_remaining, snapshot.budget_max
                ),
            ],
        };

        if let Some(session_id) = snapshot.session_id.as_deref()
            && let Some(db) = agent.state_db_handle()
        {
            match db.get_session(session_id) {
                Ok(Some(record)) => {
                    meta.id = record.id;
                    meta.title = record.title.unwrap_or_else(|| "Current session".into());
                    meta.source = record.source;
                    meta.model = record.model.unwrap_or(snapshot.model);
                    meta.started_label = format_session_timestamp(record.started_at);
                    meta.last_active_label = "active now".into();
                }
                Ok(None) => {}
                Err(e) => self.push_output(format!("DB error: {e}"), OutputRole::Error),
            }
        }

        let items = messages
            .iter()
            .enumerate()
            .map(|(index, message)| SessionMessageEntry::from_message(index, message))
            .collect::<Vec<_>>();

        self.session_browser.active = false;
        self.close_detail_fullscreen(DetailSurface::SessionBrowser);
        self.close_detail_fullscreen(DetailSurface::SessionInspector);
        self.session_inspector.selector.query.clear();
        self.session_inspector.selector.selected = 0;
        self.session_inspector.selector.set_items(items);
        self.session_inspector.session = Some(meta);
        self.session_inspector.pane.reset();
        self.session_inspector.return_to_browser = false;
        self.session_inspector.selector.active = true;
        self.needs_redraw = true;
    }

    fn close_session_inspector(&mut self) {
        let return_to_browser = self.session_inspector.return_to_browser;
        self.close_detail_fullscreen(DetailSurface::SessionInspector);
        self.session_inspector.close();
        self.session_browser.active = return_to_browser;
        self.needs_redraw = true;
    }

    fn delete_session_from_browser(&mut self, session_id: &str) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        match agent.delete_session(session_id) {
            Ok(()) => {
                self.push_output(
                    format!(
                        "Deleted session {}",
                        &session_id[..session_id.len().min(12)]
                    ),
                    OutputRole::System,
                );
                let had_query = !self.session_browser.query.trim().is_empty();
                if self.refresh_session_browser()
                    && self.session_browser.items.is_empty()
                    && !had_query
                {
                    self.session_browser.active = false;
                    self.close_detail_fullscreen(DetailSurface::SessionBrowser);
                }
            }
            Err(e) => self.push_output(format!("Delete failed: {e}"), OutputRole::Error),
        }
    }

    fn open_mcp_selector(&mut self, initial_query: Option<&str>, refresh_catalog: bool) -> usize {
        let configured = self.configured_mcp_servers(true).unwrap_or_default();
        let official_entries = if refresh_catalog {
            self.rt_handle
                .block_on(crate::mcp_catalog::load_official_catalog(true))
        } else {
            crate::mcp_catalog::load_official_catalog_cached()
        };
        let entries = build_mcp_selector_entries_from(&configured, &official_entries);
        self.mcp_selector.set_items(entries);
        self.reset_split_detail_scroll(DetailSurface::McpSelector);
        self.mcp_selector.active = true;
        if let Some(query) = initial_query
            .map(str::trim)
            .filter(|query| !query.is_empty())
        {
            self.mcp_selector.query = query.to_string();
            self.mcp_selector.update_filter();
        }
        self.needs_redraw = true;
        self.mcp_selector.filtered.len()
    }

    fn configured_mcp_servers(
        &self,
        include_disabled: bool,
    ) -> Result<Vec<edgecrab_tools::tools::mcp_client::ConfiguredMcpServer>, String> {
        let servers = if include_disabled {
            edgecrab_tools::tools::mcp_client::configured_servers_with_disabled()
        } else {
            edgecrab_tools::tools::mcp_client::configured_servers()
        };
        servers.map_err(|err| format!("Failed to read MCP configuration: {err}"))
    }

    fn configured_mcp_server(
        &self,
        name: &str,
    ) -> Result<edgecrab_tools::tools::mcp_client::ConfiguredMcpServer, String> {
        self.configured_mcp_servers(true)?
            .into_iter()
            .find(|server| server.name == name)
            .ok_or_else(|| format!("Unknown MCP preset or configured server '{name}'."))
    }

    fn set_mcp_server_enabled(&mut self, name: &str, enabled: bool) {
        let mut config = self.load_runtime_config();
        let Some(server) = config.mcp_servers.get_mut(name) else {
            self.push_output(format!("Unknown MCP server '{name}'."), OutputRole::Error);
            return;
        };

        if server.enabled == enabled {
            self.push_output(
                format!(
                    "MCP server '{}' is already {}.",
                    name,
                    if enabled { "enabled" } else { "disabled" }
                ),
                OutputRole::System,
            );
            return;
        }

        server.enabled = enabled;
        match config.save() {
            Ok(()) => {
                edgecrab_tools::tools::mcp_client::reload_mcp_connections();
                self.push_output(
                    format!(
                        "{} MCP server '{}'.",
                        if enabled { "Enabled" } else { "Disabled" },
                        name
                    ),
                    OutputRole::System,
                );
            }
            Err(err) => self.push_output(
                format!(
                    "Failed to save config after {} MCP server '{}': {err}",
                    if enabled { "enabling" } else { "disabling" },
                    name
                ),
                OutputRole::Error,
            ),
        }
    }

    fn handle_session_browser_command(&mut self, query: Option<String>) {
        self.open_session_browser_with_query(query.as_deref());
    }

    fn handle_inspect_current_session(&mut self) {
        self.open_current_session_inspector();
    }

    fn handle_session_delete(&mut self, id_prefix: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        if !agent.has_state_db() {
            self.push_output("No state database configured.", OutputRole::System);
            return;
        }
        match agent.list_sessions(100) {
            Ok(sessions) => {
                let matches: Vec<_> = sessions
                    .iter()
                    .filter(|s| s.id.starts_with(&id_prefix))
                    .collect();
                match matches.len() {
                    0 => self.push_output(
                        format!("No session matching '{id_prefix}'"),
                        OutputRole::Error,
                    ),
                    1 => {
                        let sid = matches[0].id.clone();
                        match agent.delete_session(&sid) {
                            Ok(()) => self.push_output(
                                format!("Deleted session {}", &sid[..sid.len().min(8)]),
                                OutputRole::System,
                            ),
                            Err(e) => {
                                self.push_output(format!("Delete failed: {e}"), OutputRole::Error)
                            }
                        }
                    }
                    n => self.push_output(
                        format!(
                            "Ambiguous prefix '{id_prefix}' matches {n} sessions — be more specific"
                        ),
                        OutputRole::Error,
                    ),
                }
            }
            Err(e) => self.push_output(format!("DB error: {e}"), OutputRole::Error),
        }
    }

    fn handle_resume_session(&mut self, id: Option<String>) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        if !agent.has_state_db() {
            self.push_output("No state database configured.", OutputRole::System);
            return;
        }
        let target = match id {
            Some(id_or_title) => {
                // Try resolve via DB (ID prefix, exact ID, title, lineage)
                let db = self.rt_handle.block_on(agent.state_db());
                match db {
                    Some(db) => match db.resolve_session(&id_or_title) {
                        Ok(Some(resolved)) => resolved,
                        Ok(None) => {
                            self.push_output(
                                format!("No session matching '{id_or_title}'"),
                                OutputRole::Error,
                            );
                            return;
                        }
                        Err(e) => {
                            self.push_output(format!("DB error: {e}"), OutputRole::Error);
                            return;
                        }
                    },
                    None => {
                        self.push_output("No state database configured.", OutputRole::System);
                        return;
                    }
                }
            }
            None => match agent.list_sessions(1) {
                Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(),
                Ok(_) => {
                    self.push_output("No saved sessions.", OutputRole::System);
                    return;
                }
                Err(e) => {
                    self.push_output(format!("DB error: {e}"), OutputRole::Error);
                    return;
                }
            },
        };

        // Build a recap of the conversation before loading it
        let restored = self.rt_handle.block_on(async {
            agent.restore_session(&target).await?;
            let messages = agent.messages().await;
            let snap = agent.session_snapshot().await;
            Ok::<_, edgecrab_types::AgentError>((messages, snap))
        });
        match restored {
            Ok((messages, snap)) => {
                // Show conversation recap before loading
                let recap = build_session_recap(&messages);
                let prompt_tokens = snap.context_pressure_tokens();
                self.load_messages(messages);
                self.model_name = snap.model;
                self.yolo_enabled = snap
                    .session_id
                    .as_deref()
                    .map(edgecrab_tools::approval_runtime::yolo_enabled_for_session)
                    .unwrap_or(false);
                self.update_context_window();
                self.total_tokens = prompt_tokens;
                if !recap.is_empty() {
                    self.push_output(recap, OutputRole::System);
                }
                self.push_output(
                    format!("Resumed session {}", &target[..target.len().min(12)]),
                    OutputRole::System,
                );
            }
            Err(e) => self.push_output(format!("Resume failed: {e}"), OutputRole::Error),
        }
    }

    fn handle_session_rename(&mut self, id_prefix: String, title: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        if !agent.has_state_db() {
            self.push_output("No state database configured.", OutputRole::System);
            return;
        }
        match agent.list_sessions(100) {
            Ok(sessions) => {
                let matches: Vec<_> = sessions
                    .iter()
                    .filter(|s| s.id.starts_with(&id_prefix))
                    .collect();
                match matches.len() {
                    0 => self.push_output(
                        format!("No session matching '{id_prefix}'"),
                        OutputRole::Error,
                    ),
                    1 => {
                        let sid = matches[0].id.clone();
                        match agent.rename_session(&sid, &title) {
                            Ok(()) => self.push_output(
                                format!("Renamed {} → \"{}\"", &sid[..sid.len().min(8)], title),
                                OutputRole::System,
                            ),
                            Err(e) => {
                                self.push_output(format!("Rename failed: {e}"), OutputRole::Error)
                            }
                        }
                    }
                    n => self.push_output(
                        format!("Ambiguous prefix '{id_prefix}' matches {n} sessions"),
                        OutputRole::Error,
                    ),
                }
            }
            Err(e) => self.push_output(format!("DB error: {e}"), OutputRole::Error),
        }
    }

    fn handle_session_prune(&mut self, older_than_days: u32) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        if !agent.has_state_db() {
            self.push_output("No state database configured.", OutputRole::System);
            return;
        }
        match agent.prune_sessions(older_than_days, None) {
            Ok(count) => self.push_output(
                format!("Pruned {count} ended session(s) older than {older_than_days} days."),
                OutputRole::System,
            ),
            Err(e) => self.push_output(format!("Prune failed: {e}"), OutputRole::Error),
        }
    }

    fn handle_background_prompt(&mut self, prompt: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let tx = self.response_tx.clone();
        self.background_task_seq = self.background_task_seq.saturating_add(1);
        let task_num = self.background_task_seq;
        let task_id = format!(
            "bg_{}_{}",
            chrono::Local::now().format("%H%M%S"),
            uuid::Uuid::new_v4().simple()
        );
        let preview = edgecrab_core::safe_truncate(&prompt, 60).to_string();
        let preview_suffix = if preview.len() < prompt.len() {
            "..."
        } else {
            ""
        };
        self.progress_seq = self.progress_seq.saturating_add(1);
        self.background_tasks_active.insert(
            task_id.clone(),
            BackgroundTaskStatus {
                preview: preview.clone(),
                last_progress: None,
                last_seq: self.progress_seq,
            },
        );
        self.push_output(
            format!(
                "🔄 Background task #{task_num} started: \"{preview}{preview_suffix}\"\nTask ID: {task_id}"
            ),
            OutputRole::System,
        );
        self.rt_handle.spawn(async move {
            let background_agent = match agent
                .fork_isolated(IsolatedAgentOptions {
                    session_id: Some(task_id.clone()),
                    quiet_mode: Some(true),
                    ..Default::default()
                })
                .await
            {
                Ok(child) => child,
                Err(e) => {
                    let _ = tx.send(AgentResponse::BackgroundPromptFailed {
                        task_num,
                        task_id,
                        error: e.to_string(),
                    });
                    return;
                }
            };

            let (event_tx, mut event_rx) =
                tokio::sync::mpsc::unbounded_channel::<edgecrab_core::StreamEvent>();
            let prompt_for_stream = prompt.clone();
            let stream_task = tokio::spawn(async move {
                background_agent
                    .chat_streaming(&prompt_for_stream, event_tx)
                    .await
            });

            let mut response = String::new();
            let mut stream_error: Option<String> = None;
            let mut last_progress: Option<String> = None;
            while let Some(event) = event_rx.recv().await {
                if let Some(text) = background_progress_text(task_num, &event)
                    && last_progress.as_deref() != Some(text.as_str())
                {
                    let _ = tx.send(AgentResponse::BackgroundPromptProgress {
                        task_id: task_id.clone(),
                        text: text.clone(),
                    });
                    last_progress = Some(text);
                }

                match event {
                    edgecrab_core::StreamEvent::Token(text) => response.push_str(&text),
                    edgecrab_core::StreamEvent::Error(err) => stream_error = Some(err),
                    edgecrab_core::StreamEvent::Done => break,
                    edgecrab_core::StreamEvent::Approval { response_tx, .. } => {
                        let _ = response_tx.send(edgecrab_core::ApprovalChoice::Deny);
                    }
                    edgecrab_core::StreamEvent::SecretRequest { response_tx, .. } => {
                        let _ = response_tx.send(String::new());
                    }
                    edgecrab_core::StreamEvent::Clarify { response_tx, .. } => {
                        let _ = response_tx.send(String::new());
                    }
                    _ => {}
                }
            }

            match stream_task.await {
                Ok(Ok(())) if stream_error.is_none() => {
                    let _ = tx.send(AgentResponse::BackgroundPromptComplete {
                        task_num,
                        task_id,
                        prompt_preview: preview,
                        response,
                    });
                }
                Ok(Ok(())) => {
                    let _ = tx.send(AgentResponse::BackgroundPromptFailed {
                        task_num,
                        task_id,
                        error: stream_error.unwrap_or_else(|| "background task failed".into()),
                    });
                }
                Ok(Err(e)) => {
                    let _ = tx.send(AgentResponse::BackgroundPromptFailed {
                        task_num,
                        task_id,
                        error: e.to_string(),
                    });
                }
                Err(e) => {
                    let _ = tx.send(AgentResponse::BackgroundPromptFailed {
                        task_num,
                        task_id,
                        error: format!("background task join error: {e}"),
                    });
                }
            }
        });
    }

    fn current_session_key(&self) -> Option<String> {
        let agent = self.agent.as_ref()?;
        agent
            .try_session_snapshot()
            .and_then(|snap| snap.session_id)
    }

    fn handle_set_yolo(&mut self, mode: String) {
        let Some(session_key) = self.current_session_key() else {
            self.push_output(
                "YOLO mode is unavailable until a session is active.",
                OutputRole::System,
            );
            return;
        };

        let action = mode.trim().to_ascii_lowercase();
        match action.as_str() {
            "" | "toggle" => {
                self.yolo_enabled = !self.yolo_enabled;
            }
            "on" | "enable" | "enabled" => {
                self.yolo_enabled = true;
            }
            "off" | "disable" | "disabled" => {
                self.yolo_enabled = false;
            }
            "status" => {
                self.push_output(
                    format!(
                        "YOLO mode is {} for this session.",
                        if self.yolo_enabled { "ON" } else { "OFF" }
                    ),
                    OutputRole::System,
                );
                return;
            }
            other => {
                self.push_output(
                    format!("Unknown yolo mode '{other}'. Use: /yolo [on|off|toggle|status]"),
                    OutputRole::System,
                );
                return;
            }
        }

        edgecrab_tools::approval_runtime::set_yolo_for_session(&session_key, self.yolo_enabled);
        self.push_output(
            format!(
                "YOLO mode {} for session {}.",
                if self.yolo_enabled {
                    "enabled"
                } else {
                    "disabled"
                },
                edgecrab_core::safe_truncate(&session_key, 12)
            ),
            OutputRole::System,
        );
    }

    fn handle_show_gateway_commands(&mut self, args: String) {
        let page = args
            .trim()
            .parse::<usize>()
            .ok()
            .filter(|page| *page > 0)
            .unwrap_or(1);
        let report = gateway_commands_page(page, &self.skills_completion_names);
        self.open_report_overlay("Gateway Commands", "Shared slash-command catalog", report);
    }

    fn handle_side_question(&mut self, question: String) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let tx = self.response_tx.clone();
        let preview = edgecrab_core::safe_truncate(question.trim(), 72).to_string();
        self.push_output(format!("💬 /btw {preview}"), OutputRole::System);

        self.rt_handle.spawn(async move {
            let history = agent.messages().await;
            let system_prompt = agent.system_prompt().await;
            let provider = agent.provider_handle().await;
            let child = match edgecrab_core::AgentBuilder::new(&agent.session_snapshot().await.model)
                .provider(provider)
                .tools(Arc::new(edgecrab_tools::registry::ToolRegistry::new()))
                .platform(edgecrab_types::Platform::Cli)
                .streaming(false)
                .quiet_mode(true)
                .max_iterations(8)
                .build()
            {
                Ok(agent) => agent,
                Err(error) => {
                    let _ = tx.send(AgentResponse::SideQuestionFailed {
                        question,
                        error: error.to_string(),
                    });
                    return;
                }
            };

            let prompt = format!(
                "[Ephemeral /btw side question. Answer from the existing conversation context. \
                 Do not call tools. Do not persist anything. Keep the answer concise.]\n\n{question}"
            );
            match child
                .run_conversation(&prompt, system_prompt.as_deref(), Some(history))
                .await
            {
                Ok(result) => {
                    let _ = tx.send(AgentResponse::SideQuestionComplete {
                        question,
                        response: result.final_response,
                    });
                }
                Err(error) => {
                    let _ = tx.send(AgentResponse::SideQuestionFailed {
                        question,
                        error: error.to_string(),
                    });
                }
            }
        });
    }

    fn handle_branch_session(&mut self, name: Option<String>) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        if !agent.has_state_db() {
            self.push_output(
                "Session branching requires the session database.",
                OutputRole::System,
            );
            return;
        }

        let session_cost = self.session_cost;
        let branch_result = self.rt_handle.block_on(async {
            let messages = agent.messages().await;
            if messages.is_empty() {
                return Err("No conversation to branch yet.".to_string());
            }

            let snapshot = agent.session_snapshot().await;
            let Some(current_session_id) = snapshot.session_id else {
                return Err("Current session has no ID.".to_string());
            };
            let Some(db) = agent.state_db().await else {
                return Err("No state database configured.".to_string());
            };

            let current_record = db
                .get_session(&current_session_id)
                .map_err(|e| e.to_string())?;
            let base_title = current_record
                .as_ref()
                .and_then(|record| record.title.as_deref())
                .filter(|title| !title.trim().is_empty())
                .unwrap_or("branch");
            let new_title = match name {
                Some(explicit) if !explicit.trim().is_empty() => explicit,
                _ => db
                    .next_title_in_lineage(base_title)
                    .map_err(|e| e.to_string())?,
            };

            let system_prompt = agent.system_prompt().await;
            let new_session_id = uuid::Uuid::new_v4().to_string();
            let now = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map_err(|e| e.to_string())?
                .as_secs_f64();

            let record = edgecrab_state::SessionRecord {
                id: new_session_id.clone(),
                source: "cli".into(),
                user_id: None,
                model: Some(snapshot.model.clone()),
                system_prompt,
                parent_session_id: Some(current_session_id.clone()),
                started_at: now,
                ended_at: Some(now),
                end_reason: Some("branched".into()),
                message_count: messages.len() as i64,
                tool_call_count: snapshot.api_call_count as i64,
                input_tokens: snapshot.input_tokens as i64,
                output_tokens: snapshot.output_tokens as i64,
                cache_read_tokens: snapshot.cache_read_tokens as i64,
                cache_write_tokens: snapshot.cache_write_tokens as i64,
                reasoning_tokens: snapshot.reasoning_tokens as i64,
                estimated_cost_usd: Some(session_cost),
                title: Some(new_title.clone()),
            };

            db.save_session_with_messages(&record, &messages, now)
                .map_err(|e| e.to_string())?;
            agent
                .restore_session(&new_session_id)
                .await
                .map_err(|e| e.to_string())?;

            Ok::<_, String>((new_session_id, new_title, messages, snapshot.model))
        });

        match branch_result {
            Ok((session_id, title, messages, model)) => {
                self.load_messages(messages);
                self.model_name = model;
                self.update_context_window();
                self.yolo_enabled = false;
                self.push_output(
                    format!(
                        "Branched session \"{title}\".\nNew session: {}",
                        edgecrab_core::safe_truncate(&session_id, 12)
                    ),
                    OutputRole::System,
                );
            }
            Err(error) => self.push_output(format!("branch: {error}"), OutputRole::Error),
        }
    }

    fn open_value_capture(
        &mut self,
        title: impl Into<String>,
        prompt: impl Into<String>,
        placeholder: impl Into<String>,
        masked: bool,
        buffer: impl Into<String>,
        action: ValueCaptureAction,
    ) {
        self.display_state = DisplayState::ValueCapture {
            title: title.into(),
            prompt: prompt.into(),
            placeholder: placeholder.into(),
            masked,
            buffer: buffer.into(),
            action,
        };
        self.needs_redraw = true;
    }

    fn open_profile_create_editor(&mut self) {
        self.open_value_capture(
            "Create Profile",
            "Create a profile inline. Syntax: <name> [--clone|--clone-all] [--clone-from source].",
            "research --clone-from work",
            false,
            "",
            ValueCaptureAction::ProfileCreate,
        );
    }

    fn open_profile_rename_editor(&mut self, old_name: String) {
        self.open_value_capture(
            format!("Rename Profile · {old_name}"),
            "Enter the new profile name.",
            "new-profile-name",
            false,
            "",
            ValueCaptureAction::ProfileRename(old_name),
        );
    }

    fn open_profile_delete_editor(&mut self, name: String) {
        let placeholder = name.clone();
        self.open_value_capture(
            format!("Delete Profile · {name}"),
            format!("Type `{name}` to confirm permanent deletion."),
            placeholder,
            false,
            "",
            ValueCaptureAction::ProfileDeleteConfirm(name),
        );
    }

    fn open_profile_alias_editor(&mut self, name: String) {
        let current_alias = self
            .profile_selector
            .current()
            .and_then(|entry| (entry.name == name).then_some(entry.name.clone()))
            .unwrap_or_else(|| name.clone());
        self.open_value_capture(
            format!("Profile Alias · {name}"),
            "Enter an alias wrapper name. Use `clear` to remove the canonical alias.",
            "work",
            false,
            current_alias,
            ValueCaptureAction::ProfileAlias(name),
        );
    }

    fn open_profile_export_editor(&mut self, name: String) {
        self.open_value_capture(
            format!("Export Profile · {name}"),
            "Optional archive path. Leave blank to write <profile>.tar.gz in the current directory.",
            format!("{name}.tar.gz"),
            false,
            "",
            ValueCaptureAction::ProfileExport(name),
        );
    }

    fn open_profile_import_editor(&mut self) {
        self.open_value_capture(
            "Import Profile",
            "Import a profile archive inline. Syntax: <archive-path> [target-name].",
            "/path/to/profile.tar.gz imported-name",
            false,
            "",
            ValueCaptureAction::ProfileImport,
        );
    }

    fn execute_profile_create(
        &mut self,
        name: &str,
        clone: bool,
        clone_all: bool,
        clone_from: Option<&str>,
    ) {
        let manager = ProfileManager::new();
        match manager.create_report(name, clone, clone_all, clone_from) {
            Ok(report) => {
                self.refresh_profiles_list();
                self.push_output(report.render(), OutputRole::System);
            }
            Err(err) => self.push_output(format!("profile create: {err}"), OutputRole::Error),
        }
    }

    fn execute_profile_delete(&mut self, name: &str) {
        let manager = ProfileManager::new();
        match manager.delete(name, true) {
            Ok(()) => {
                self.refresh_profiles_list();
                self.push_output(format!("Profile '{}' deleted.", name), OutputRole::System);
            }
            Err(err) => self.push_output(format!("profile delete: {err:#}"), OutputRole::Error),
        }
    }

    fn execute_profile_rename(&mut self, old_name: &str, new_name: &str) {
        let manager = ProfileManager::new();
        let current = manager.active();
        match manager.rename(old_name, new_name) {
            Ok(()) => {
                self.refresh_profiles_list();
                if current == old_name {
                    self.handle_profile_switch(new_name.to_string());
                } else {
                    self.push_output(
                        format!("Profile '{}' renamed to '{}'.", old_name, new_name),
                        OutputRole::System,
                    );
                }
            }
            Err(err) => self.push_output(format!("profile rename: {err}"), OutputRole::Error),
        }
    }

    fn execute_profile_alias(&mut self, name: &str, remove: bool, alias_name: Option<&str>) {
        let manager = ProfileManager::new();
        match manager.alias(name, remove, alias_name) {
            Ok(()) => {
                self.refresh_profiles_list();
                self.push_output(
                    format!("Alias operation completed for profile '{}'.", name),
                    OutputRole::System,
                );
            }
            Err(err) => self.push_output(format!("profile alias: {err}"), OutputRole::Error),
        }
    }

    fn execute_profile_export(&mut self, name: &str, output: Option<&str>) {
        let manager = ProfileManager::new();
        match manager.export(name, output) {
            Ok(()) => self.push_output(format!("Profile '{}' exported.", name), OutputRole::System),
            Err(err) => self.push_output(format!("profile export: {err}"), OutputRole::Error),
        }
    }

    fn execute_profile_import(&mut self, archive: &str, name: Option<&str>) {
        let manager = ProfileManager::new();
        match manager.import(archive, name) {
            Ok(()) => {
                self.refresh_profiles_list();
                self.push_output("Profile imported.", OutputRole::System);
            }
            Err(err) => self.push_output(format!("profile import: {err}"), OutputRole::Error),
        }
    }

    fn handle_profile_switch(&mut self, name: String) {
        if self.is_processing {
            self.push_output(
                "Finish or stop the current request before switching profiles.",
                OutputRole::System,
            );
            return;
        }

        let manager = ProfileManager::new();
        if let Err(err) = manager.set_active_profile(&name) {
            self.push_output(format!("profile use: {err}"), OutputRole::Error);
            return;
        }

        if let Some(agent) = self.agent.clone() {
            self.rt_handle.block_on(async {
                agent.finalize_session().await;
            });
        }

        if let Err(err) = crate::profile::activate_profile(Some(&name)) {
            self.push_output(format!("profile switch: {err}"), OutputRole::Error);
            return;
        }

        let runtime = match load_runtime(None, None, None) {
            Ok(runtime) => runtime,
            Err(err) => {
                self.push_output(format!("profile switch: {err}"), OutputRole::Error);
                return;
            }
        };

        let model = runtime.config.model.default_model.clone();
        let provider = match crate::create_provider(&model) {
            Ok(provider) => provider,
            Err(err) => {
                self.push_output(format!("profile switch: {err}"), OutputRole::Error);
                return;
            }
        };
        let state_db = match open_state_db(&runtime.state_db_path) {
            Ok(db) => db,
            Err(err) => {
                self.push_output(format!("profile switch: {err}"), OutputRole::Error);
                return;
            }
        };
        let tool_registry = self
            .rt_handle
            .block_on(build_tool_registry_with_mcp_discovery(&runtime.config));
        let new_agent = match self.rt_handle.block_on(build_agent(
            &runtime,
            provider,
            state_db,
            tool_registry,
            edgecrab_types::Platform::Cli,
            false,
            None,
        )) {
            Ok(agent) => agent,
            Err(err) => {
                self.push_output(format!("profile switch: {err}"), OutputRole::Error);
                return;
            }
        };
        if let Err(err) =
            self.rt_handle
                .block_on(crate::gateway_cmd::attach_gateway_sender_if_running(
                    &new_agent, &runtime,
                ))
        {
            self.push_output(format!("profile switch: {err}"), OutputRole::Error);
            return;
        }

        self.agent = Some(new_agent);
        self.set_model(&model);
        self.total_tokens = 0;
        self.session_cost = 0.0;
        self.turn_count = 0;
        self.last_response_time = None;
        self.last_agent_response_text.clear();
        self.buffered_assistant_output.clear();
        self.streaming_line = None;
        self.reasoning_line = None;
        self.in_flight_tool_count = 0;
        self.active_tools.clear();
        self.active_subagents.clear();
        self.pending_tool_lines.clear();
        self.prompt_queue.clear();
        self.active_skills.clear();
        self.display_state = DisplayState::Idle;
        self.voice_input_device = runtime.config.voice.input_device;
        self.voice_push_to_talk_key = runtime.config.voice.push_to_talk_key;
        self.voice_continuous_default = runtime.config.voice.continuous;
        self.voice_hallucination_filter = runtime.config.voice.hallucination_filter;
        edgecrab_tools::tools::mcp_client::reload_mcp_connections();
        self.refresh_skills_list();
        self.refresh_profiles_list();
        self.profile_selector.active = false;
        self.close_detail_fullscreen(DetailSurface::ProfileSelector);
        self.clear_output();
        self.push_output(
            format!(
                "Switched to profile '{}'.\nHome: {}\nModel: {}\nUse /profile or /profiles to reopen the profile control overlay.",
                name,
                edgecrab_core::edgecrab_home().display(),
                model
            ),
            OutputRole::System,
        );
        self.needs_redraw = true;
    }

    fn handle_show_profiles(&mut self, args: String) {
        let args = args.trim().to_string();

        if args.is_empty() || args == "status" {
            let manager = ProfileManager::new();
            self.push_output(manager.render_active_status(), OutputRole::System);
            return;
        }

        let tokens = match shell_words::split(&args) {
            Ok(tokens) => tokens,
            Err(err) => {
                self.push_output(format!("profile: {err}"), OutputRole::Error);
                return;
            }
        };
        if tokens.is_empty() {
            self.push_output(
                "Usage: /profile [show|config|soul|memory|tools|list|use|create|delete|rename|alias|export|import|help]",
                OutputRole::System,
            );
            return;
        }

        match tokens[0].as_str() {
            "help" => self.open_profile_selector_with(None, ProfileDetailMode::Help),
            "list" | "ls" => self.open_profile_selector_with(None, ProfileDetailMode::Summary),
            "show" => {
                let active = ProfileManager::new().active();
                let name = tokens.get(1).map(String::as_str).unwrap_or(active.as_str());
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Summary);
            }
            "config" => {
                let active = ProfileManager::new().active();
                let name = tokens.get(1).map(String::as_str).unwrap_or(active.as_str());
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Config);
            }
            "soul" => {
                let active = ProfileManager::new().active();
                let name = tokens.get(1).map(String::as_str).unwrap_or(active.as_str());
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Soul);
            }
            "memory" => {
                let active = ProfileManager::new().active();
                let name = tokens.get(1).map(String::as_str).unwrap_or(active.as_str());
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Memory);
            }
            "tools" => {
                let active = ProfileManager::new().active();
                let name = tokens.get(1).map(String::as_str).unwrap_or(active.as_str());
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Tools);
            }
            "use" => {
                let active = ProfileManager::new().active();
                let Some(name) = tokens.get(1).map(String::as_str).or(Some(active.as_str())) else {
                    self.push_output("Usage: /profile use <name>", OutputRole::System);
                    return;
                };
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Summary);
            }
            "create" => {
                let initial = tokens[1..].join(" ");
                self.open_profile_selector_with(None, ProfileDetailMode::Help);
                self.open_value_capture(
                    "Create Profile",
                    "Create a profile inline. Syntax: <name> [--clone|--clone-all] [--clone-from source].",
                    "research --clone-from work",
                    false,
                    initial,
                    ValueCaptureAction::ProfileCreate,
                );
            }
            "delete" => {
                let Some(name) = tokens.get(1) else {
                    self.open_profile_selector_with(None, ProfileDetailMode::Help);
                    return;
                };
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Summary);
                self.open_profile_delete_editor(name.clone());
            }
            "rename" => {
                let Some(old_name) = tokens.get(1) else {
                    self.open_profile_selector_with(None, ProfileDetailMode::Help);
                    return;
                };
                self.open_profile_selector_with(Some(old_name), ProfileDetailMode::Summary);
                self.open_value_capture(
                    format!("Rename Profile · {old_name}"),
                    "Enter the new profile name.",
                    "new-profile-name",
                    false,
                    tokens.get(2).cloned().unwrap_or_default(),
                    ValueCaptureAction::ProfileRename(old_name.clone()),
                );
            }
            "alias" => {
                let Some(name) = tokens.get(1) else {
                    self.open_profile_selector_with(None, ProfileDetailMode::Help);
                    return;
                };
                let remove = tokens.iter().any(|token| token == "--remove");
                let alias_name = tokens
                    .windows(2)
                    .find_map(|window| (window[0] == "--name").then_some(window[1].as_str()));
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Summary);
                self.open_value_capture(
                    format!("Profile Alias · {name}"),
                    "Enter an alias wrapper name. Use `clear` to remove the canonical alias.",
                    name,
                    false,
                    if remove {
                        "clear".into()
                    } else {
                        alias_name.unwrap_or(name).to_string()
                    },
                    ValueCaptureAction::ProfileAlias(name.clone()),
                );
            }
            "export" => {
                let Some(name) = tokens.get(1) else {
                    self.open_profile_selector_with(None, ProfileDetailMode::Help);
                    return;
                };
                let output = tokens.windows(2).find_map(|window| {
                    (window[0] == "-o" || window[0] == "--output").then_some(window[1].as_str())
                });
                self.open_profile_selector_with(Some(name), ProfileDetailMode::Summary);
                self.open_value_capture(
                    format!("Export Profile · {name}"),
                    "Optional archive path. Leave blank to write <profile>.tar.gz in the current directory.",
                    format!("{name}.tar.gz"),
                    false,
                    output.unwrap_or_default(),
                    ValueCaptureAction::ProfileExport(name.clone()),
                );
            }
            "import" => {
                let initial_name = tokens
                    .windows(2)
                    .find_map(|window| (window[0] == "--name").then_some(window[1].as_str()));
                let initial = match (tokens.get(1), initial_name) {
                    (Some(archive), Some(name)) => format!("{archive} {name}"),
                    (Some(archive), None) => archive.clone(),
                    (None, _) => String::new(),
                };
                self.open_profile_selector_with(None, ProfileDetailMode::Help);
                self.open_value_capture(
                    "Import Profile",
                    "Import a profile archive inline. Syntax: <archive-path> [target-name].",
                    "/path/to/profile.tar.gz imported-name",
                    false,
                    initial,
                    ValueCaptureAction::ProfileImport,
                );
            }
            other => self.push_output(
                format!(
                    "Unknown profile subcommand '{}'. Use /profile help or /profiles.",
                    other
                ),
                OutputRole::System,
            ),
        }
    }

    fn handle_show_skills(&mut self, args: String) {
        // Use ~/.edgecrab/skills/ — mirrors skills tool (edgecrab_home-based)
        let skills_dir = edgecrab_core::edgecrab_home().join("skills");

        let mut parts = args.trim().splitn(2, ' ');
        let subcommand = parts.next().unwrap_or("").trim();
        let operand = parts.next().unwrap_or("").trim();

        match subcommand {
            "" | "list" | "ls" => {
                if !skills_dir.exists() {
                    self.push_output(
                        format!("Skills directory not found: {}\nCreate it and add .md skill files.", skills_dir.display()),
                        OutputRole::System,
                    );
                    return;
                }
                match std::fs::read_dir(&skills_dir) {
                    Ok(entries) => {
                        let mut skills: Vec<_> = entries
                            .filter_map(|e| e.ok())
                            .filter(|e| {
                                let p = e.path();
                                p.extension().is_some_and(|ext| ext == "md")
                                    || p.is_dir()
                            })
                            .collect();
                        if skills.is_empty() {
                            self.push_output(
                                "No skills installed. Add .md files or skill directories to ~/.edgecrab/skills/\n\
                                 Run `/skills search` to browse remote skills or `/skills install <path>` to install a local skill.",
                                OutputRole::System,
                            );
                        } else {
                            skills.sort_by_key(|e| e.file_name());
                            let mut text = format!("Skills ({}):\n", skills.len());
                            for s in &skills {
                                let fname = s.file_name();
                                let name = fname.to_string_lossy();
                                let skill_type = if s.path().is_dir() { "[dir]" } else { "[md]" };
                                text.push_str(&format!("  {skill_type} {name}\n"));
                            }
                            text.push_str(
                                "\nUsage: /skills view <name.md>  /skills search  /skills install <path>",
                            );
                            self.open_report_overlay(
                                "Installed Skills",
                                format!("{} local skill entries", skills.len()),
                                text,
                            );
                        }
                    }
                    Err(e) => self.push_output(format!("Cannot read skills dir: {e}"), OutputRole::Error),
                }
            }

            "view" => {
                if operand.is_empty() {
                    self.push_output("Usage: /skills view <skill-name.md>", OutputRole::System);
                    return;
                }
                // Try the operand as-is, then with .md appended, then as a directory
                let candidates = vec![
                    skills_dir.join(operand),
                    skills_dir.join(format!("{operand}.md")),
                    skills_dir.join(operand).join("SKILL.md"),
                ];
                let skill_file = candidates.into_iter().find(|p| p.is_file());
                match skill_file {
                    Some(path) => {
                        match std::fs::read_to_string(&path) {
                            Ok(content) => {
                                let header = format!("=== {} ===\n", path.file_name().unwrap_or_default().to_string_lossy());
                                self.open_report_overlay(
                                    format!(
                                        "Skill · {}",
                                        path.file_name().unwrap_or_default().to_string_lossy()
                                    ),
                                    path.display().to_string(),
                                    format!("{header}{content}"),
                                );
                            }
                            Err(e) => self.push_output(format!("Cannot read skill: {e}"), OutputRole::Error),
                        }
                    }
                    None => {
                        self.push_output(
                            format!("Skill '{}' not found in {}", operand, skills_dir.display()),
                            OutputRole::Error,
                        );
                    }
                }
            }

            "install" => {
                if operand.is_empty() {
                    self.push_output(
                        "Usage:\n\
                         /skills install <local-path>              — install local skill file/dir\n\
                         /skills install edgecrab:<path>           — install from a curated remote source\n\
                         /skills install owner/repo/path/skill.md  — install from GitHub",
                        OutputRole::System,
                    );
                    return;
                }

                // Detect GitHub-style operand: at least 2 slashes, not an absolute path
                let looks_like_github = !operand.starts_with('/')
                    && !operand.starts_with('.')
                    && !operand.starts_with('~')
                    && operand.matches('/').count() >= 2
                    && !std::path::Path::new(operand).exists();

                if looks_like_github {
                    let skills_dir_c = skills_dir.clone();
                    let optional_dir = edgecrab_tools::tools::skills_sync::optional_skills_dir();
                    let remote_id = operand.to_string();

                    self.push_output(
                        format!("Fetching remote skill bundle '{remote_id}' …"),
                        OutputRole::System,
                    );

                    let result = self.rt_handle.block_on(async {
                        edgecrab_tools::tools::skills_hub::install_identifier(
                            &remote_id,
                            &skills_dir_c,
                            optional_dir.as_deref(),
                            false,
                        )
                        .await
                    });

                    match result {
                        Ok(outcome) => self.push_output(
                            format!(
                                "{}\nActivate with: /skills view {}",
                                outcome.message, outcome.skill_name
                            ),
                            OutputRole::System,
                        ),
                        Err(e)  => self.push_output(format!("Remote install failed: {e}"), OutputRole::Error),
                    }
                    return;
                }

                let src = std::path::Path::new(operand);
                if !src.exists() {
                    let skills_dir_c = skills_dir.clone();
                    let optional_dir = edgecrab_tools::tools::skills_sync::optional_skills_dir();
                    let remote_id = operand.to_string();
                    self.push_output(
                        format!("Fetching remote skill bundle '{remote_id}' …"),
                        OutputRole::System,
                    );
                    let result = self.rt_handle.block_on(async {
                        edgecrab_tools::tools::skills_hub::install_identifier(
                            &remote_id,
                            &skills_dir_c,
                            optional_dir.as_deref(),
                            false,
                        )
                        .await
                    });
                    match result {
                        Ok(outcome) => self.push_output(
                            format!(
                                "{}\nActivate with: /skills view {}",
                                outcome.message, outcome.skill_name
                            ),
                            OutputRole::System,
                        ),
                        Err(e) => {
                            self.push_output(format!("Remote install failed: {e}"), OutputRole::Error)
                        }
                    }
                    return;
                }
                // Create skills dir if needed
                if let Err(e) = std::fs::create_dir_all(&skills_dir) {
                    self.push_output(format!("Cannot create skills dir: {e}"), OutputRole::Error);
                    return;
                }
                if src.is_file() {
                    let dest = skills_dir.join(src.file_name().unwrap_or_default());
                    match std::fs::copy(src, &dest) {
                        Ok(_) => self.push_output(
                            format!("Skill installed: {}", dest.file_name().unwrap_or_default().to_string_lossy()),
                            OutputRole::System,
                        ),
                        Err(e) => self.push_output(format!("Install failed: {e}"), OutputRole::Error),
                    }
                } else if src.is_dir() {
                    let dir_name = src.file_name().unwrap_or_default();
                    let dest = skills_dir.join(dir_name);
                    match copy_dir_recursive(src, &dest) {
                        Ok(n) => self.push_output(
                            format!("Skill directory '{}' installed ({n} files).", dir_name.to_string_lossy()),
                            OutputRole::System,
                        ),
                        Err(e) => self.push_output(format!("Install failed: {e}"), OutputRole::Error),
                    }
                }
            }

            "update" => {
                let skills_dir_c = skills_dir.clone();
                let optional_dir = edgecrab_tools::tools::skills_sync::optional_skills_dir();
                if operand.is_empty() {
                    self.push_output(
                        "Updating all hub-installed remote skills …",
                        OutputRole::System,
                    );
                    let result = self.rt_handle.block_on(async {
                        edgecrab_tools::tools::skills_hub::update_all_installed_skills(
                            &skills_dir_c,
                            optional_dir.as_deref(),
                            false,
                        )
                        .await
                    });
                    match result {
                        Ok(outcomes) => self.push_output(
                            edgecrab_tools::tools::skills_hub::render_update_outcomes(&outcomes),
                            OutputRole::System,
                        ),
                        Err(e) => self.push_output(format!("Remote update failed: {e}"), OutputRole::Error),
                    }
                } else {
                    let skill_name = operand.to_string();
                    self.push_output(
                        format!("Updating remote skill '{skill_name}' …"),
                        OutputRole::System,
                    );
                    let result = self.rt_handle.block_on(async {
                        edgecrab_tools::tools::skills_hub::update_installed_skill(
                            &skill_name,
                            &skills_dir_c,
                            optional_dir.as_deref(),
                            false,
                        )
                        .await
                    });
                    match result {
                        Ok(outcome) => self.push_output(
                            format!(
                                "{}\nActivate with: /skills view {}",
                                outcome.message, outcome.skill_name
                            ),
                            OutputRole::System,
                        ),
                        Err(e) => self.push_output(format!("Remote update failed: {e}"), OutputRole::Error),
                    }
                }
            }

            "hub" | "search" => {
                let query = operand;
                self.open_remote_skill_selector((!query.is_empty()).then_some(query));
            }

            "remove" | "uninstall" | "rm" => {
                if operand.is_empty() {
                    self.push_output("Usage: /skills remove <skill-name>", OutputRole::System);
                    return;
                }
                let candidates = vec![
                    skills_dir.join(operand),
                    skills_dir.join(format!("{operand}.md")),
                ];
                let target = candidates.into_iter().find(|p| p.exists());
                match target {
                    Some(path) if path.is_file() => {
                        match std::fs::remove_file(&path) {
                            Ok(_) => self.push_output(format!("Skill '{}' removed.", operand), OutputRole::System),
                            Err(e) => self.push_output(format!("Remove failed: {e}"), OutputRole::Error),
                        }
                    }
                    Some(path) if path.is_dir() => {
                        match std::fs::remove_dir_all(&path) {
                            Ok(_) => self.push_output(format!("Skill directory '{}' removed.", operand), OutputRole::System),
                            Err(e) => self.push_output(format!("Remove failed: {e}"), OutputRole::Error),
                        }
                    }
                    _ => self.push_output(format!("Skill '{}' not found.", operand), OutputRole::Error),
                }
            }

            _ => self.push_output(
                "Usage: /skills [list | view <name> | install <path-or-source-or-owner/repo/path> | update [name] | remove <name> | hub [query] | search [query]]",
                OutputRole::System,
            ),
        }
    }

    fn remove_reasoning_output_block(&mut self) {
        if let Some(idx) = self.reasoning_line.take()
            && idx < self.output.len()
        {
            self.output.remove(idx);
            if let Some(stream_idx) = self.streaming_line {
                self.streaming_line = match stream_idx.cmp(&idx) {
                    std::cmp::Ordering::Greater => Some(stream_idx - 1),
                    std::cmp::Ordering::Equal => None,
                    std::cmp::Ordering::Less => Some(stream_idx),
                };
            }
            self.needs_redraw = true;
        }
    }

    fn set_reasoning_visibility(&mut self, enabled: bool) -> anyhow::Result<()> {
        self.show_reasoning = enabled;
        if !enabled {
            self.remove_reasoning_output_block();
        }
        persist_display_preferences(Some(enabled), None, None, None)
    }

    fn set_streaming_preference(&mut self, enabled: bool) -> anyhow::Result<()> {
        self.streaming_enabled = enabled;
        self.live_token_display_enabled = enabled;
        if let Some(agent) = self.agent.clone() {
            self.rt_handle.block_on(async {
                agent.set_streaming(enabled).await;
            });
        }
        persist_display_preferences(None, Some(enabled), None, None)
    }

    fn set_tool_progress_mode(&mut self, mode: ToolProgressMode) -> anyhow::Result<()> {
        self.tool_progress_mode = mode;
        persist_display_preferences(None, None, Some(mode), None)
    }

    fn set_status_bar_visibility(&mut self, enabled: bool) -> anyhow::Result<()> {
        self.show_status_bar = enabled;
        self.needs_redraw = true;
        persist_display_preferences(None, None, None, Some(enabled))
    }

    fn should_render_tool_call(&mut self, tool_name: &str, args_json: &str) -> bool {
        match self.tool_progress_mode {
            ToolProgressMode::Off => false,
            ToolProgressMode::All | ToolProgressMode::Verbose => true,
            ToolProgressMode::New => {
                let signature = tool_signature(tool_name, args_json);
                self.seen_tool_signatures.insert(signature)
            }
        }
    }

    fn tool_progress_mode_detail(mode: ToolProgressMode) -> &'static str {
        match mode {
            ToolProgressMode::Off => "silent transcript; status bar still shows active work.",
            ToolProgressMode::New => "show each distinct tool call once per turn.",
            ToolProgressMode::All => "show every tool call in the transcript.",
            ToolProgressMode::Verbose => {
                "show every tool call plus curated detail lines for plan and result context."
            }
        }
    }

    fn format_tool_progress_status(&self) -> String {
        format!(
            "Tool progress: {} - {} Modes: off, new, all, verbose.",
            self.tool_progress_mode.label(),
            Self::tool_progress_mode_detail(self.tool_progress_mode)
        )
    }

    fn open_tool_progress_selector(&mut self) -> String {
        self.verbose_selector_cursor = match self.tool_progress_mode {
            ToolProgressMode::Off => 0,
            ToolProgressMode::New => 1,
            ToolProgressMode::All => 2,
            ToolProgressMode::Verbose => 3,
        };
        self.verbose_selector_active = true;
        self.needs_redraw = true;
        self.format_tool_progress_status()
    }

    fn cycle_tool_progress_mode(&mut self) -> String {
        let next = self.tool_progress_mode.cycle();
        let save_note = match self.set_tool_progress_mode(next) {
            Ok(()) => String::new(),
            Err(err) => format!(" Saving config failed: {err}"),
        };
        let detail = Self::tool_progress_mode_detail(next);
        format!("Tool progress: {} - {detail}{save_note}", next.label())
    }

    fn handle_tool_progress_command(&mut self, raw: String) -> String {
        let normalized = raw.trim().to_ascii_lowercase();
        match normalized.as_str() {
            "" | "cycle" | "next" => self.cycle_tool_progress_mode(),
            "open" => self.open_tool_progress_selector(),
            "status" => self.format_tool_progress_status(),
            "off" => self.set_tool_progress_mode_explicit(ToolProgressMode::Off),
            "new" => self.set_tool_progress_mode_explicit(ToolProgressMode::New),
            "all" => self.set_tool_progress_mode_explicit(ToolProgressMode::All),
            "verbose" => self.set_tool_progress_mode_explicit(ToolProgressMode::Verbose),
            _ => "Usage: /verbose [off|new|all|verbose|status|open]".into(),
        }
    }

    fn set_tool_progress_mode_explicit(&mut self, mode: ToolProgressMode) -> String {
        let already_active = self.tool_progress_mode == mode;
        let save_note = match self.set_tool_progress_mode(mode) {
            Ok(()) => String::new(),
            Err(err) => format!(" Saving config failed: {err}"),
        };
        let detail = Self::tool_progress_mode_detail(mode);
        if already_active {
            format!(
                "Tool progress: {} - already active. {detail}{save_note}",
                mode.label()
            )
        } else {
            format!("Tool progress: {} - {detail}{save_note}", mode.label())
        }
    }

    fn handle_set_reasoning(&mut self, level: String) {
        let normalized = level.trim().to_ascii_lowercase();
        let partial_note = if self.is_processing {
            " Existing output updates immediately; full effect is guaranteed on the next prompt."
        } else {
            ""
        };

        let msg = match normalized.as_str() {
            "low" | "medium" | "high" => {
                // Cache the last-set effort so the picker can pre-select it.
                self.reasoning_effort_hint = Some(normalized.clone());
                if let Some(agent) = self.agent.clone() {
                    self.rt_handle.block_on(async {
                        agent.set_reasoning_effort(Some(normalized.clone())).await;
                    });
                }
                format!("Reasoning effort set to: {normalized}")
            }
            "show" => match self.set_reasoning_visibility(true) {
                Ok(()) => {
                    format!("Think mode: ON — reasoning will appear above answers.{partial_note}")
                }
                Err(e) => {
                    format!("Think mode: ON for this session, but saving config failed: {e}")
                }
            },
            "hide" => match self.set_reasoning_visibility(false) {
                Ok(()) => {
                    format!(
                        "Think mode: OFF — reasoning is hidden from the output pane.{partial_note}"
                    )
                }
                Err(e) => {
                    format!("Think mode: OFF for this session, but saving config failed: {e}")
                }
            },
            "" | "status" => {
                let state = if self.show_reasoning { "on" } else { "off" };
                format!("Think mode: {state}. Usage: /reasoning <low|medium|high|show|hide|status>")
            }
            _ => "Unknown reasoning option. Use: low, medium, high, show, hide, status".into(),
        };
        self.push_output(msg, OutputRole::System);
    }

    fn handle_set_streaming(&mut self, mode: String) {
        let normalized = mode.trim().to_ascii_lowercase();
        let defer_note = if self.is_processing {
            " The current prompt keeps its existing behavior; this applies fully on the next prompt."
        } else {
            ""
        };

        let msg = match normalized.as_str() {
            "" | "status" => {
                let api_state = if self.streaming_enabled { "on" } else { "off" };
                let display_state = if self.live_token_display_enabled {
                    "on"
                } else {
                    "off"
                };
                if api_state == display_state {
                    format!("Streaming: {api_state}. Usage: /stream <on|off|toggle|status>")
                } else {
                    format!(
                        "Streaming API: {api_state}. Live token display: {display_state}. Usage: /stream <on|off|toggle|status>"
                    )
                }
            }
            "on" | "enable" | "enabled" => match self.set_streaming_preference(true) {
                Ok(()) => {
                    format!("Streaming: ON — live token updates are enabled.{defer_note}")
                }
                Err(e) => {
                    format!("Streaming: ON for this session, but saving config failed: {e}")
                }
            },
            "off" | "disable" | "disabled" => match self.set_streaming_preference(false) {
                Ok(()) => {
                    format!(
                        "Streaming: OFF — replies will appear as a complete answer.{defer_note}"
                    )
                }
                Err(e) => {
                    format!("Streaming: OFF for this session, but saving config failed: {e}")
                }
            },
            "toggle" => {
                if self.streaming_enabled {
                    match self.set_streaming_preference(false) {
                        Ok(()) => format!(
                            "Streaming: OFF — replies will appear as a complete answer.{defer_note}"
                        ),
                        Err(e) => format!(
                            "Streaming: OFF for this session, but saving config failed: {e}"
                        ),
                    }
                } else {
                    match self.set_streaming_preference(true) {
                        Ok(()) => {
                            format!("Streaming: ON — live token updates are enabled.{defer_note}")
                        }
                        Err(e) => {
                            format!("Streaming: ON for this session, but saving config failed: {e}")
                        }
                    }
                }
            }
            _ => "Unknown streaming option. Use: on, off, toggle, status".into(),
        };
        self.push_output(msg, OutputRole::System);
    }

    fn handle_status_bar_command(&mut self, mode: String) {
        let normalized = mode.trim().to_ascii_lowercase();
        let msg = match normalized.as_str() {
            "" | "status" => format!(
                "Status bar: {}. Usage: /statusbar <on|off|toggle|status>",
                if self.show_status_bar {
                    "visible"
                } else {
                    "hidden"
                }
            ),
            "on" | "show" | "enable" | "enabled" => match self.set_status_bar_visibility(true) {
                Ok(()) => "Status bar: visible.".into(),
                Err(err) => {
                    format!("Status bar enabled for this session, but saving config failed: {err}")
                }
            },
            "off" | "hide" | "disable" | "disabled" => {
                match self.set_status_bar_visibility(false) {
                    Ok(()) => "Status bar: hidden.".into(),
                    Err(err) => format!(
                        "Status bar disabled for this session, but saving config failed: {err}"
                    ),
                }
            }
            "toggle" => {
                let next = !self.show_status_bar;
                match self.set_status_bar_visibility(next) {
                    Ok(()) => format!("Status bar: {}.", if next { "visible" } else { "hidden" }),
                    Err(err) => format!(
                        "Status bar changed for this session, but saving config failed: {err}"
                    ),
                }
            }
            _ => "Unknown status bar option. Use: on, off, toggle, status".into(),
        };
        self.push_output(msg, OutputRole::System);
    }

    fn apply_model_selector_catalog(
        &mut self,
        models: Vec<ModelEntry>,
        current_model: &str,
        preserve_state: bool,
        target: ModelSelectorTarget,
    ) {
        self.model_selector_target = target;
        self.reset_split_detail_scroll(DetailSurface::ModelSelector);
        if preserve_state {
            if self.model_selector.active {
                self.model_selector.replace_items_preserving_state(models);
                if self.model_selector.filtered.is_empty() {
                    self.model_selector.activate_with_primary(current_model);
                }
            } else {
                self.model_selector.set_items(models);
            }
        } else {
            self.model_selector.set_items(models);
            self.model_selector.activate_with_primary(current_model);
        }
        self.needs_redraw = true;
    }

    fn open_model_selector_for(&mut self, current_model: &str, target: ModelSelectorTarget) {
        self.ensure_model_selector_seeded();
        let entries = self.model_selector.items.clone();
        self.apply_model_selector_catalog(entries, current_model, false, target);
    }

    fn refresh_shared_model_selector_catalog(
        &mut self,
        current_model: String,
        target: ModelSelectorTarget,
    ) {
        self.open_model_selector_for(&current_model, target);
        if self.model_selector_refresh_in_flight {
            return;
        }

        let mut providers: Vec<String> = discovery_provider_statuses()
            .into_iter()
            .filter_map(|(provider, availability)| {
                matches!(availability, DiscoveryAvailability::Supported).then_some(provider)
            })
            .collect();
        if let Some((provider, _)) = current_model.split_once('/') {
            let provider = normalize_discovery_provider(provider);
            if matches!(
                live_discovery_availability(&provider),
                DiscoveryAvailability::Supported
            ) && !providers.iter().any(|p| p == &provider)
            {
                providers.push(provider);
            }
        }
        let tx = self.response_tx.clone();

        self.model_selector_refresh_in_flight = true;
        self.needs_redraw = true;

        self.rt_handle.spawn(async move {
            let static_catalog = ModelCatalog::grouped_catalog();
            let discovered = discover_multiple(&providers).await;
            let merged = merge_grouped_catalog_with_dynamic(&static_catalog, &discovered);
            let dynamic_lookup: BTreeMap<String, (DiscoverySource, BTreeSet<String>)> = discovered
                .into_iter()
                .map(|entry| {
                    (
                        entry.provider,
                        (entry.source, entry.models.into_iter().collect()),
                    )
                })
                .collect();
            let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::ModelCatalogReady {
                models: build_model_selector_entries(&merged, Some(&dynamic_lookup)),
                current_model,
            }));
        });
    }

    /// Spawn background live discovery while keeping the selector interactive.
    fn refresh_model_selector_catalog(&mut self) {
        self.refresh_shared_model_selector_catalog(
            self.model_name.clone(),
            ModelSelectorTarget::Primary,
        );
    }

    fn open_vision_model_selector(&mut self) {
        let current_model = self.model_name.clone();
        let auxiliary = self
            .agent
            .as_ref()
            .map(|agent| self.rt_handle.block_on(agent.auxiliary_config()))
            .unwrap_or_default();
        let dynamic_providers: Vec<String> = live_discovery_providers()
            .into_iter()
            .filter(|provider| provider == "ollama" || provider == "lmstudio")
            .collect();
        let dynamic_models = self
            .rt_handle
            .block_on(discover_multiple(&dynamic_providers));
        let dynamic_pairs: Vec<(String, Vec<String>)> = dynamic_models
            .into_iter()
            .map(|entry| (entry.provider, entry.models))
            .collect();

        let auto_detail = if current_model_supports_vision(&current_model) {
            format!("Auto policy - current chat model {current_model} is vision-capable")
        } else {
            format!(
                "Auto policy — reuse {current_model} when possible, otherwise fall back to configured vision backends"
            )
        };

        let mut entries = vec![ModelEntry {
            display: "auto".to_string(),
            provider: "policy".to_string(),
            model_name: "auto".to_string(),
            detail: auto_detail,
        }];

        entries.extend(
            available_vision_model_options_with_dynamic(&dynamic_pairs)
                .into_iter()
                .map(|option| ModelEntry {
                    display: option.selection_spec,
                    provider: option.provider,
                    model_name: option.model,
                    detail: option.detail,
                }),
        );

        let current_primary = match (auxiliary.provider.as_deref(), auxiliary.model.as_deref()) {
            (Some(provider), Some(model))
                if !provider.trim().is_empty() && !model.trim().is_empty() =>
            {
                let provider = match provider.trim() {
                    "vscode-copilot" => "copilot",
                    "gemini" => "google",
                    other => other,
                };
                format!("{}/{}", provider, model.trim())
            }
            _ => "auto".to_string(),
        };

        self.vision_model_selector.set_items(entries);
        self.reset_split_detail_scroll(DetailSurface::VisionModelSelector);
        self.vision_model_selector
            .activate_with_primary(&current_primary);
    }

    fn handle_list_models(&mut self, args: String) {
        if matches!(self.display_state, DisplayState::BgOp { .. }) {
            self.push_output("Model discovery already in progress…", OutputRole::System);
            return;
        }
        let static_catalog = ModelCatalog::grouped_catalog();
        let known_providers = ModelCatalog::provider_ids();
        let current = self.model_name.clone();
        let trimmed = args.trim();

        if let Some(refresh_target) = trimmed.strip_prefix("refresh") {
            let target = refresh_target.trim().to_lowercase();
            let discovery_statuses = discovery_provider_statuses();
            let feature_gated: Vec<String> = discovery_statuses
                .iter()
                .filter_map(|(provider, availability)| match availability {
                    DiscoveryAvailability::FeatureGated(feature) => {
                        Some(format!("{provider} ({feature})"))
                    }
                    _ => None,
                })
                .collect();
            let providers: Vec<String> = if target.is_empty() || target == "all" {
                discovery_statuses
                    .into_iter()
                    .filter_map(|(provider, availability)| {
                        matches!(availability, DiscoveryAvailability::Supported).then_some(provider)
                    })
                    .collect()
            } else {
                let canonical = normalize_discovery_provider(&target);
                match live_discovery_availability(&canonical) {
                    DiscoveryAvailability::Supported => vec![canonical],
                    DiscoveryAvailability::FeatureGated(feature) => {
                        self.push_output(
                            format!(
                                "Provider '{target}' supports live discovery but this build was compiled without `{feature}`."
                            ),
                            OutputRole::System,
                        );
                        return;
                    }
                    DiscoveryAvailability::Unsupported => {
                        self.push_output(
                            format!(
                                "Provider '{target}' does not support live discovery. It is listed from the static catalog."
                            ),
                            OutputRole::System,
                        );
                        return;
                    }
                }
            };
            let tx = self.response_tx.clone();
            self.display_state = DisplayState::BgOp {
                label: "Discovering models…".to_string(),
                frame: 0,
                started: Instant::now(),
            };
            self.needs_redraw = true;
            self.rt_handle.spawn(async move {
                let discovered = discover_multiple(&providers).await;
                let mut text = String::from("Model discovery refresh:\n\n");
                for entry in discovered {
                    let source = discovery_source_label(entry.source);
                    text.push_str(&format!(
                        "  {}: {} models ({})\n",
                        entry.provider,
                        entry.models.len(),
                        source
                    ));
                }
                if !feature_gated.is_empty() {
                    text.push_str("\nFeature-gated in this build:\n");
                    for provider in feature_gated {
                        text.push_str(&format!("  {provider}\n"));
                    }
                }
                text.push_str(
                    "\nUse /models <provider> to inspect the list, or /model to open selector.",
                );
                let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::SystemMsg(text)));
            });
            return;
        }

        let filter = trimmed.to_lowercase();
        let canonical_filter = normalize_discovery_provider(&filter);
        let is_exact_provider = !filter.is_empty()
            && (known_providers
                .iter()
                .any(|provider| provider == &canonical_filter)
                || matches!(
                    live_discovery_availability(&canonical_filter),
                    DiscoveryAvailability::Supported | DiscoveryAvailability::FeatureGated(_)
                ));

        if is_exact_provider {
            let filter_owned = canonical_filter.clone();
            match live_discovery_availability(&filter_owned) {
                DiscoveryAvailability::Supported => {
                    let tx = self.response_tx.clone();
                    let current_model = current.clone();
                    self.display_state = DisplayState::BgOp {
                        label: format!("Discovering {filter_owned}…"),
                        frame: 0,
                        started: Instant::now(),
                    };
                    self.needs_redraw = true;
                    self.rt_handle.spawn(async move {
                        let discovered = discover_provider_models(&filter_owned).await;
                        let text = build_provider_models_report(
                            &discovered,
                            DiscoveryAvailability::Supported,
                            &current_model,
                        );
                        let _ = tx.send(AgentResponse::BgOp(BackgroundOpResult::SystemMsg(text)));
                    });
                }
                DiscoveryAvailability::FeatureGated(feature) => {
                    let provider_models = ProviderModels {
                        provider: filter_owned.clone(),
                        models: static_catalog
                            .iter()
                            .find(|(provider, _)| provider == &filter_owned)
                            .map(|(_, models)| models.clone())
                            .unwrap_or_default(),
                        source: DiscoverySource::Static,
                    };
                    let text = build_provider_models_report(
                        &provider_models,
                        DiscoveryAvailability::FeatureGated(feature),
                        &current,
                    );
                    self.push_output(text, OutputRole::System);
                }
                DiscoveryAvailability::Unsupported => {
                    let provider_models = ProviderModels {
                        provider: filter_owned.clone(),
                        models: static_catalog
                            .iter()
                            .find(|(provider, _)| provider == &filter_owned)
                            .map(|(_, models)| models.clone())
                            .unwrap_or_default(),
                        source: DiscoverySource::Static,
                    };
                    let text = build_provider_models_report(
                        &provider_models,
                        DiscoveryAvailability::Unsupported,
                        &current,
                    );
                    self.push_output(text, OutputRole::System);
                }
            }
            return;
        }

        let filtered_providers: Vec<(String, Vec<String>)> = static_catalog
            .into_iter()
            .filter(|(provider, _)| filter.is_empty() || provider.contains(&filter))
            .collect();

        let text = if filtered_providers.is_empty() {
            format!(
                "No provider matching '{}'. Use /models without args to see all.\n",
                filter
            )
        } else {
            build_models_inventory_report(&filtered_providers, &current, &filter)
        };

        self.open_report_overlay(
            "Usage Insights",
            "Current session plus 30-day historical usage",
            text,
        );
    }

    // ─── Wired slash command handlers (Phase 8.1) ───────────────────

    /// Handle `/cron [subcommand] [args]` from the TUI input bar.
    ///
    /// Supported subcommands (parity with `hermes cron`):
    ///   list [--all]             — show scheduled jobs (markdown)
    ///   add <schedule> <prompt>  — create a new job (alias: create)
    ///   pause <id>               — pause a job
    ///   resume <id>              — resume a paused job
    ///   run <id>                 — trigger job on next scheduler tick
    ///   remove <id>              — delete a job (alias: rm, delete)
    ///   status                   — show status summary (markdown)
    ///   help                     — full command reference (markdown)
    ///   (empty)                  — same as "status"
    ///
    /// All outputs are pushed as `OutputRole::Assistant` so the TUI
    /// markdown renderer applies full styling (headers, bold, code spans,
    /// blockquotes).
    fn handle_show_cron_status(&mut self, args: String) {
        use crate::cron_cmd;

        let parts: Vec<&str> = args.split_whitespace().collect();
        let sub = parts.first().copied().unwrap_or("");

        let result: anyhow::Result<String> = match sub {
            // ── display subcommands → markdown ──────────────────────────────
            "" | "status" => cron_cmd::status_md(),
            "list" | "ls" => {
                let show_all = parts.contains(&"--all") || parts.contains(&"-a");
                cron_cmd::list_jobs_md(show_all)
            }
            "help" => Ok(cron_cmd::cron_help_md()),

            // ── mutation subcommands ─────────────────────────────────────────
            "add" | "create" => {
                // /cron add [--deliver <target>] <schedule> <prompt...>
                // --deliver/-d may appear anywhere before the schedule/prompt.
                // Schedule may be "every 2h" (two tokens) or "0 9 * * *" (5 tokens).
                if parts.len() < 3 {
                    Err(anyhow::anyhow!(
                        "Usage: `/cron add <schedule> <prompt>`\n\n\
                         **Examples:**\n\
                         - `/cron add 30m Check the build`\n\
                         - `/cron add every 2h Summarize news`\n\
                         - `/cron add 0 9 * * * Morning briefing`\n\
                         - `/cron add --deliver telegram 0 9 * * * Morning HN summary`\n\
                         - `/cron add --deliver origin every 2h Check server status`"
                    ))
                } else {
                    // Strip --deliver/-d <value> from candidates before schedule parsing.
                    let mut deliver: Option<String> = None;
                    let raw_candidates = &parts[1..];
                    let mut stripped: Vec<&str> = Vec::new();
                    let mut skip_next = false;
                    for (i, tok) in raw_candidates.iter().enumerate() {
                        if skip_next {
                            skip_next = false;
                            continue;
                        }
                        if *tok == "--deliver" || *tok == "-d" {
                            if let Some(val) = raw_candidates.get(i + 1) {
                                deliver = Some(val.to_string());
                            }
                            skip_next = true;
                        } else if let Some(val) = tok.strip_prefix("--deliver=") {
                            deliver = Some(val.to_string());
                        } else {
                            stripped.push(tok);
                        }
                    }

                    if stripped.len() < 2 {
                        Err(anyhow::anyhow!(
                            "Usage: `/cron add <schedule> <prompt>` — both schedule and prompt are required.\n\
                             Run `/cron help` for the full reference."
                        ))
                    } else {
                        let mut found: Option<(String, String)> = None;
                        for sched_len in 1..stripped.len() {
                            let sched = stripped[..sched_len].join(" ");
                            if edgecrab_cron::parse_schedule(&sched).is_ok() {
                                let prompt = stripped[sched_len..].join(" ");
                                if !prompt.is_empty() {
                                    found = Some((sched, prompt));
                                    break;
                                }
                            }
                        }
                        match found {
                            Some((schedule, prompt)) => {
                                // Default deliver to "origin" (the TUI terminal) when the
                                // user doesn't specify --deliver. This makes the cron output
                                // appear inline in this chat rather than only saving to a file.
                                let effective_deliver = deliver.as_deref().or(Some("origin"));
                                cron_cmd::create_job_text(
                                    &schedule,
                                    &prompt,
                                    None,
                                    &[],
                                    None,
                                    effective_deliver,
                                )
                            }
                            None => Err(anyhow::anyhow!(
                                "Could not parse schedule from: `{}`\n\n\
                             Try: `/cron add 30m <prompt>`  or  `/cron add every 2h <prompt>`\n\
                             Run `/cron help` for schedule format reference.",
                                stripped.join(" ")
                            )),
                        }
                    } // else stripped.len() >= 2
                }
            }
            "pause" => {
                let id = parts.get(1).copied().unwrap_or("");
                if id.is_empty() {
                    Err(anyhow::anyhow!("Usage: `/cron pause <job_id>`"))
                } else {
                    cron_cmd::pause_job_text(id)
                }
            }
            "resume" => {
                let id = parts.get(1).copied().unwrap_or("");
                if id.is_empty() {
                    Err(anyhow::anyhow!("Usage: `/cron resume <job_id>`"))
                } else {
                    cron_cmd::resume_job_text(id)
                }
            }
            "run" | "trigger" => {
                let id = parts.get(1).copied().unwrap_or("");
                if id.is_empty() {
                    Err(anyhow::anyhow!("Usage: `/cron run <job_id>`"))
                } else {
                    cron_cmd::trigger_job_text(id)
                }
            }
            "remove" | "rm" | "delete" => {
                let id = parts.get(1).copied().unwrap_or("");
                if id.is_empty() {
                    Err(anyhow::anyhow!("Usage: `/cron remove <job_id>`"))
                } else {
                    cron_cmd::remove_job_text(id)
                }
            }
            other => Err(anyhow::anyhow!(
                "Unknown cron subcommand `{other}`.\n\n\
                 Available: `list` `add` `pause` `resume` `run` `remove` `status` `help`\n\
                 Run `/cron help` for the full command reference."
            )),
        };

        // All cron output uses Assistant role so the TUI markdown renderer
        // applies full styling (H2/H3 headers, bold, inline code, blockquotes).
        match result {
            Ok(text) => self.push_output(text, OutputRole::Assistant),
            Err(e) => self.push_output(format!("cron: {e}"), OutputRole::Error),
        }
    }

    fn handle_show_plugins(&mut self, args: String) {
        let trimmed = args.trim();
        if trimmed.is_empty() || matches!(trimmed, "list" | "ls") {
            self.open_plugin_toggle(PluginScope::Global);
            return;
        }
        let action = crate::plugins_cmd::action_from_slash_args(&args)
            .unwrap_or(crate::plugins_cmd::PluginAction::List);
        let refresh_runtime = matches!(
            &action,
            crate::plugins_cmd::PluginAction::Install { .. }
                | crate::plugins_cmd::PluginAction::Enable { .. }
                | crate::plugins_cmd::PluginAction::Disable { .. }
                | crate::plugins_cmd::PluginAction::Toggle { .. }
                | crate::plugins_cmd::PluginAction::Update { .. }
                | crate::plugins_cmd::PluginAction::Remove { .. }
        );
        match action {
            crate::plugins_cmd::PluginAction::Search { query, source } => {
                self.open_remote_plugin_selector(
                    (!query.is_empty()).then_some(query.trim()),
                    source.as_deref(),
                );
            }
            crate::plugins_cmd::PluginAction::Browse => {
                self.open_remote_plugin_selector(None, None);
            }
            crate::plugins_cmd::PluginAction::Status => {
                match crate::plugins_cmd::run_capture(crate::plugins_cmd::PluginAction::Status) {
                    Ok(text) => self.open_report_overlay(
                        "Plugin Status",
                        "Installed plugin runtime and health summary",
                        text,
                    ),
                    Err(error) => self.push_output(format!("plugins: {error}"), OutputRole::Error),
                }
            }
            crate::plugins_cmd::PluginAction::Info { name } => {
                match crate::plugins_cmd::run_capture(crate::plugins_cmd::PluginAction::Info {
                    name: name.clone(),
                }) {
                    Ok(text) => self.open_report_overlay(
                        format!("Plugin · {name}"),
                        "Installed plugin detail",
                        text,
                    ),
                    Err(error) => self.push_output(format!("plugins: {error}"), OutputRole::Error),
                }
            }
            crate::plugins_cmd::PluginAction::Audit { lines } => {
                match crate::plugins_cmd::run_capture(crate::plugins_cmd::PluginAction::Audit {
                    lines,
                }) {
                    Ok(text) => self.open_report_overlay(
                        "Plugin Audit",
                        format!("Recent plugin audit trail ({lines} lines requested)"),
                        text,
                    ),
                    Err(error) => self.push_output(format!("plugins: {error}"), OutputRole::Error),
                }
            }
            other => match crate::plugins_cmd::run_capture(other) {
                Ok(text) => {
                    if refresh_runtime && let Err(error) = self.refresh_agent_plugin_runtime() {
                        self.push_output(
                            format!("Plugin runtime refresh failed: {error}"),
                            OutputRole::Error,
                        );
                    }
                    self.push_output(text, OutputRole::Assistant);
                }
                Err(error) => self.push_output(format!("plugins: {error}"), OutputRole::Error),
            },
        }
    }

    fn handle_show_platforms(&mut self) {
        self.open_gateway_browser();
    }

    fn open_gateway_browser(&mut self) {
        self.gateway_browser.query.clear();
        self.gateway_browser.selected = 0;
        self.gateway_browser.active = true;
        self.gateway_browser_pane.reset();
        self.refresh_gateway_browser();
    }

    fn refresh_gateway_browser(&mut self) {
        let config = self.load_runtime_config();
        let diagnostics = collect_platform_diagnostics(&config);
        let entries = build_gateway_platform_entries(&config, &diagnostics);
        self.gateway_browser.replace_items_preserving_state(entries);
        self.needs_redraw = true;
    }

    fn gateway_browser_current(&self) -> Option<&GatewayPlatformEntry> {
        self.gateway_browser.current()
    }

    fn toggle_selected_gateway_platform(&mut self) {
        let Some(platform_id) = self
            .gateway_browser_current()
            .map(|entry| entry.diagnostic.id)
        else {
            return;
        };

        let mut config = self.load_runtime_config();
        let enabled_now = match platform_id {
            "telegram" => config
                .gateway
                .platform_requested(platform_id, config.gateway.telegram.enabled),
            "discord" => config
                .gateway
                .platform_requested(platform_id, config.gateway.discord.enabled),
            "slack" => config
                .gateway
                .platform_requested(platform_id, config.gateway.slack.enabled),
            "signal" => config
                .gateway
                .platform_requested(platform_id, config.gateway.signal.enabled),
            "whatsapp" => config
                .gateway
                .platform_requested(platform_id, config.gateway.whatsapp.enabled),
            "webhook" => config.gateway.webhook_enabled,
            _ => config.gateway.platform_enabled(platform_id),
        };

        if platform_id == "webhook" {
            config.gateway.webhook_enabled = !enabled_now;
        } else {
            if !enabled_now {
                config.gateway.enable_platform(platform_id);
            } else {
                config.gateway.disable_platform(platform_id);
            }
            match platform_id {
                "telegram" => config.gateway.telegram.enabled = !enabled_now,
                "discord" => config.gateway.discord.enabled = !enabled_now,
                "slack" => config.gateway.slack.enabled = !enabled_now,
                "signal" => config.gateway.signal.enabled = !enabled_now,
                "whatsapp" => config.gateway.whatsapp.enabled = !enabled_now,
                _ => {}
            }
        }

        match config.save() {
            Ok(()) => self.push_output(
                format!(
                    "{} {}.",
                    platform_id,
                    if enabled_now { "disabled" } else { "enabled" }
                ),
                OutputRole::System,
            ),
            Err(error) => self.push_output(
                format!("Updated {platform_id} in memory, but saving config failed: {error}"),
                OutputRole::Error,
            ),
        }
        self.refresh_gateway_browser();
    }

    fn open_gateway_bind_editor(&mut self) {
        let config = self.load_runtime_config();
        self.open_value_capture(
            "Gateway Bind",
            "Edit the bind address as host:port. Use `clear` to reset to 127.0.0.1:8080.",
            "127.0.0.1:8080",
            false,
            format!("{}:{}", config.gateway.host, config.gateway.port),
            ValueCaptureAction::BindAddress,
        );
    }

    fn open_gateway_home_channel_editor(&mut self) {
        let Some(entry) = self.gateway_browser_current() else {
            return;
        };
        if !supports_home_channel(entry.diagnostic.id) {
            self.push_output(
                format!(
                    "{} does not use a home-channel route.",
                    entry.diagnostic.name
                ),
                OutputRole::System,
            );
            return;
        }

        let config = self.load_runtime_config();
        let current = match entry.diagnostic.id {
            "telegram" => config.gateway.telegram.home_channel.unwrap_or_default(),
            "discord" => config.gateway.discord.home_channel.unwrap_or_default(),
            "slack" => config.gateway.slack.home_channel.unwrap_or_default(),
            _ => String::new(),
        };
        self.open_value_capture(
            format!("{} Home Channel", entry.diagnostic.name),
            "Set the proactive delivery target. Use `clear` to remove it.",
            "chat-id, channel-id, or thread target",
            false,
            current,
            ValueCaptureAction::HomeChannel(entry.diagnostic.id.into()),
        );
    }

    fn open_gateway_allowlist_editor(&mut self) {
        let Some(entry) = self.gateway_browser_current() else {
            return;
        };
        if !supports_allowlist(entry.diagnostic.id) {
            self.push_output(
                format!(
                    "{} has no allowlist field to edit here.",
                    entry.diagnostic.name
                ),
                OutputRole::System,
            );
            return;
        }

        let config = self.load_runtime_config();
        let current = match entry.diagnostic.id {
            "telegram" => config.gateway.telegram.allowed_users.join(","),
            "discord" => config.gateway.discord.allowed_users.join(","),
            "slack" => config.gateway.slack.allowed_users.join(","),
            "signal" => config.gateway.signal.allowed_users.join(","),
            "whatsapp" => config.gateway.whatsapp.allowed_users.join(","),
            _ => crate::gateway_setup::read_env_key(&format!(
                "{}_ALLOWED_USERS",
                entry.diagnostic.id.to_ascii_uppercase()
            ))
            .unwrap_or_default(),
        };

        self.open_value_capture(
            format!("{} Allowlist", entry.diagnostic.name),
            "Comma-separated IDs. Leave blank or use `clear` for open access.",
            "user-1,user-2",
            false,
            current,
            ValueCaptureAction::AllowedUsers(entry.diagnostic.id.into()),
        );
    }

    fn open_gateway_primary_editor(&mut self) {
        let Some(entry) = self.gateway_browser_current().cloned() else {
            return;
        };

        if let Some((title, prompt, placeholder, masked, current, action)) =
            self.gateway_primary_capture_for(&entry)
        {
            self.open_value_capture(title, prompt, placeholder, masked, current, action);
        } else {
            self.push_output(
                format!(
                    "{} does not expose a single-step inline editor yet. Use `edgecrab gateway configure {}` for the full workflow.",
                    entry.diagnostic.name, entry.diagnostic.id
                ),
                OutputRole::System,
            );
        }
    }

    fn gateway_primary_capture_for(
        &self,
        entry: &GatewayPlatformEntry,
    ) -> Option<(String, String, String, bool, String, ValueCaptureAction)> {
        let config = self.load_runtime_config();
        match entry.diagnostic.id {
            "telegram" => Some((
                "Telegram Bot Token".into(),
                "Paste the bot token. Use `clear` to remove it.".into(),
                "123456:ABCDEF...".into(),
                true,
                String::new(),
                ValueCaptureAction::PrimaryField("TELEGRAM_BOT_TOKEN".into()),
            )),
            "discord" => Some((
                "Discord Bot Token".into(),
                "Paste the bot token. Use `clear` to remove it.".into(),
                "discord bot token".into(),
                true,
                String::new(),
                ValueCaptureAction::PrimaryField("DISCORD_BOT_TOKEN".into()),
            )),
            "slack" => {
                let key = if entry
                    .diagnostic
                    .missing_required
                    .contains(&"SLACK_APP_TOKEN")
                    && !entry
                        .diagnostic
                        .missing_required
                        .contains(&"SLACK_BOT_TOKEN")
                {
                    "SLACK_APP_TOKEN"
                } else {
                    "SLACK_BOT_TOKEN"
                };
                Some((
                    format!("Slack {}", key.strip_prefix("SLACK_").unwrap_or(key)),
                    "Paste the token. Use `clear` to remove it.".into(),
                    key.to_ascii_lowercase(),
                    true,
                    String::new(),
                    ValueCaptureAction::PrimaryField(key.into()),
                ))
            }
            "signal" => {
                let missing_url = entry
                    .diagnostic
                    .missing_required
                    .contains(&"SIGNAL_HTTP_URL");
                if missing_url || config.gateway.signal.http_url.is_none() {
                    Some((
                        "Signal Daemon URL".into(),
                        "Set the signal-cli HTTP endpoint. Use `clear` to remove it.".into(),
                        "http://127.0.0.1:8081".into(),
                        false,
                        config.gateway.signal.http_url.unwrap_or_default(),
                        ValueCaptureAction::PrimaryField("SIGNAL_HTTP_URL".into()),
                    ))
                } else {
                    Some((
                        "Signal Account".into(),
                        "Set the registered Signal account number. Use `clear` to remove it."
                            .into(),
                        "+15551234567".into(),
                        false,
                        config.gateway.signal.account.unwrap_or_default(),
                        ValueCaptureAction::PrimaryField("SIGNAL_ACCOUNT".into()),
                    ))
                }
            }
            "whatsapp" => Some((
                "WhatsApp Mode".into(),
                "Set `bot` or `self-chat`. Use `clear` to reset to self-chat.".into(),
                "bot or self-chat".into(),
                false,
                config.gateway.whatsapp.mode,
                ValueCaptureAction::PrimaryField("WHATSAPP_MODE".into()),
            )),
            "webhook" => None,
            other => {
                let def = crate::gateway_catalog::find_platform(other)?;
                let field = def
                    .env_fields
                    .iter()
                    .find(|field| {
                        entry
                            .diagnostic
                            .missing_required
                            .iter()
                            .any(|missing| missing == &field.key)
                    })
                    .or_else(|| def.env_fields.iter().find(|field| field.required))
                    .or_else(|| def.env_fields.first())?;
                Some((
                    field.prompt.into(),
                    format!("{} Use `clear` to remove it.", field.help),
                    field.default_value.unwrap_or("").into(),
                    field.kind == crate::gateway_catalog::FieldKind::Secret,
                    if field.kind == crate::gateway_catalog::FieldKind::Secret {
                        String::new()
                    } else {
                        crate::gateway_setup::read_env_key(field.key).unwrap_or_default()
                    },
                    ValueCaptureAction::PrimaryField(field.key.into()),
                ))
            }
        }
    }

    fn handle_show_personality(&mut self) {
        let config = self.load_runtime_config();
        let home = edgecrab_core::edgecrab_home();
        let global_soul = home.join("SOUL.md");
        let configured = config.display.personality.trim();
        let session = self.session_personality.as_deref().unwrap_or("(none)");

        let mut text = String::from("Personalities\n");
        text.push_str(&format!(
            "SOUL.md:    {}\n",
            if global_soul.exists() {
                "~/.edgecrab/SOUL.md"
            } else {
                "(built-in fallback identity)"
            }
        ));
        text.push_str(&format!(
            "Config:     {}\n",
            if configured.is_empty() {
                "default"
            } else {
                configured
            }
        ));
        text.push_str(&format!("Session:    {session}\n\n"));
        text.push_str("Available:\n");
        text.push_str("  clear        Remove the session overlay\n");
        for (name, preview) in edgecrab_core::config::personality_catalog(&config) {
            text.push_str(&format!(
                "  {name:<12} {}\n",
                truncate_preview(&preview, 72)
            ));
        }
        text.push_str("\nUsage: /personality <name>");
        self.push_output(text, OutputRole::System);
    }

    fn handle_switch_personality(&mut self, name: String) {
        let config = self.load_runtime_config();
        let name = name.trim().to_ascii_lowercase();

        if name == "show" {
            self.handle_show_personality();
            return;
        }

        // "clear" / "default" / "none" removes the active overlay
        if matches!(name.as_str(), "clear" | "default" | "none" | "neutral") {
            self.session_personality = None;
            if let Some(agent) = self.agent.clone() {
                let configured = edgecrab_core::config::resolve_personality(
                    &config,
                    &config.display.personality,
                );
                self.rt_handle.block_on(async {
                    agent.set_personality_addon(configured).await;
                    agent
                        .inject_assistant_context(
                            "System note: the temporary personality overlay was cleared. Future replies must follow the active base persona only.",
                        )
                        .await;
                });
            }
            self.push_output("Personality overlay cleared.", OutputRole::System);
            return;
        }

        match edgecrab_core::config::resolve_personality(&config, &name) {
            Some(overlay_text) => {
                self.session_personality = Some(name.clone());
                if let Some(agent) = self.agent.clone() {
                    let note = format!(
                        "System note: personality switched to '{name}'. This supersedes any previous session style. Future replies must follow only this personality."
                    );
                    self.rt_handle.block_on(async {
                        agent.set_personality_addon(Some(overlay_text)).await;
                        agent.inject_assistant_context(&note).await;
                    });
                }
                self.push_output(
                    format!("Personality switched to '{name}'."),
                    OutputRole::System,
                );
            }
            None => {
                let presets = edgecrab_core::config::personality_catalog(&config)
                    .into_iter()
                    .map(|(name, _)| name)
                    .collect::<Vec<_>>()
                    .join(", ");
                self.push_output(
                    format!("Unknown personality '{name}'. Available: {presets}\nUse /personality clear to remove the current overlay."),
                    OutputRole::System,
                );
            }
        }
    }

    fn load_runtime_config(&self) -> edgecrab_core::AppConfig {
        let config_path = edgecrab_core::edgecrab_home().join("config.yaml");
        edgecrab_core::AppConfig::load_from(&config_path).unwrap_or_default()
    }

    fn direct_media_tool_context(&self) -> ToolContext {
        let config = self.load_runtime_config();
        ToolContext {
            task_id: "cli-voice-tool".into(),
            cwd: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
            session_id: "cli-voice-tool".into(),
            user_task: None,
            cancel: CancellationToken::new(),
            config: AppConfigRef {
                edgecrab_home: edgecrab_core::edgecrab_home(),
                tts_provider: Some(config.tts.provider),
                tts_voice: Some(config.tts.voice),
                tts_rate: config.tts.rate,
                tts_model: config.tts.model,
                tts_elevenlabs_voice_id: config.tts.elevenlabs_voice_id,
                tts_elevenlabs_model_id: config.tts.elevenlabs_model_id,
                tts_elevenlabs_api_key_env: Some(config.tts.elevenlabs_api_key_env),
                stt_provider: Some(config.stt.provider),
                stt_whisper_model: Some(config.stt.whisper_model),
                image_provider: Some(config.image_generation.provider),
                image_model: Some(config.image_generation.model),
                ..Default::default()
            },
            state_db: None,
            platform: edgecrab_types::Platform::Cli,
            process_table: None,
            provider: None,
            tool_registry: None,
            delegate_depth: 0,
            sub_agent_runner: None,
            delegation_event_tx: None,
            clarify_tx: None,
            approval_tx: None,
            on_skills_changed: None,
            gateway_sender: None,
            origin_chat: None,
            session_key: None,
            todo_store: None,
            current_tool_call_id: None,
            current_tool_name: None,
            injected_messages: None,
            tool_progress_tx: None,
            watch_notification_tx: None,
        }
    }

    fn spawn_direct_tts(&mut self, text: String, announce: bool) {
        let Some(text) = sanitize_text_for_tts(&text, 4000) else {
            if announce {
                self.push_output(
                    "Nothing speakable remained after stripping markdown and media tags."
                        .to_string(),
                    OutputRole::System,
                );
            }
            return;
        };
        self.voice_playback_active = true;
        let tx = self.response_tx.clone();
        let ctx = self.direct_media_tool_context();
        self.rt_handle.spawn(async move {
            match TextToSpeechTool
                .execute(serde_json::json!({ "text": text }), &ctx)
                .await
            {
                Ok(output) => {
                    let Some(audio_path) =
                        edgecrab_tools::tools::tts::extract_audio_path_from_tts_output(&output)
                    else {
                        let _ = tx.send(AgentResponse::Error(
                            "TTS generated output without a playable audio path.".into(),
                        ));
                        return;
                    };

                    let audio_path_buf = std::path::PathBuf::from(&audio_path);
                    match play_audio_file(&audio_path_buf).await {
                        Ok(player) => {
                            if announce {
                                let _ = tx.send(AgentResponse::DirectToolOutput(format!(
                                    "Voice playback via `{player}`.\n{output}"
                                )));
                            }
                            let temp_tts_dir = std::env::temp_dir().join("edgecrab_tts");
                            if audio_path_buf.starts_with(&temp_tts_dir) {
                                let _ = tokio::fs::remove_file(&audio_path_buf).await;
                            }
                        }
                        Err(error) => {
                            let _ = tx.send(AgentResponse::Error(format!(
                                "Voice playback failed: {error}\nGenerated audio: {audio_path}"
                            )));
                        }
                    }
                }
                Err(error) => {
                    let _ = tx.send(AgentResponse::Error(format!("TTS error: {error}")));
                }
            }
            let _ = tx.send(AgentResponse::VoicePlaybackFinished);
        });
    }

    fn spawn_direct_transcription(&mut self, file_path: String, submit_to_agent: bool) {
        let tx = self.response_tx.clone();
        let ctx = self.direct_media_tool_context();
        self.rt_handle.spawn(async move {
            match TranscribeAudioTool
                .execute(serde_json::json!({ "file_path": file_path }), &ctx)
                .await
            {
                Ok(output) => {
                    let transcript = output
                        .split_once('\n')
                        .map(|(_, text)| text.trim().to_string())
                        .unwrap_or_else(|| output.trim().to_string());
                    let _ = tx.send(AgentResponse::VoiceTranscript {
                        transcript,
                        submit_to_agent,
                        meta: None,
                    });
                }
                Err(error) => {
                    let _ = tx.send(AgentResponse::VoiceCaptureFailed {
                        error: format!("Transcription error: {error}"),
                        continuous_session: false,
                    });
                }
            }
        });
    }

    fn spawn_direct_transcription_with_cleanup(
        &mut self,
        file_path: String,
        submit_to_agent: bool,
        cleanup_after: bool,
        meta: VoiceTranscriptMeta,
    ) {
        let tx = self.response_tx.clone();
        let ctx = self.direct_media_tool_context();
        self.rt_handle.spawn(async move {
            let result = TranscribeAudioTool
                .execute(serde_json::json!({ "file_path": file_path }), &ctx)
                .await;
            if cleanup_after {
                let _ = tokio::fs::remove_file(&file_path).await;
            }

            match result {
                Ok(output) => {
                    let transcript = output
                        .split_once('\n')
                        .map(|(_, text)| text.trim().to_string())
                        .unwrap_or_else(|| output.trim().to_string());
                    let _ = tx.send(AgentResponse::VoiceTranscript {
                        transcript,
                        submit_to_agent,
                        meta: Some(meta),
                    });
                }
                Err(error) => {
                    let _ = tx.send(AgentResponse::VoiceCaptureFailed {
                        error: format!("Transcription error: {error}"),
                        continuous_session: meta.continuous_session,
                    });
                }
            }
        });
    }

    fn voice_recording_profile(&self, submit_to_agent: bool) -> VoiceRecordingProfile {
        let runtime_config = self.load_runtime_config();
        let stop_mode = if submit_to_agent && self.voice_continuous_active {
            VoiceRecordingStopMode::AutoSilence
        } else {
            VoiceRecordingStopMode::Manual
        };
        VoiceRecordingProfile {
            stop_mode,
            silence_threshold_db: runtime_config.stt.silence_threshold,
            silence_duration_ms: runtime_config.stt.silence_duration_ms,
        }
    }

    fn begin_continuous_voice_session(&mut self, persist_default: bool) {
        let profile = self.voice_recording_profile(true);
        if let Err(reason) =
            voice_recording_ready(self.voice_input_device.as_deref(), profile.stop_mode)
        {
            self.push_output(
                format!("Continuous voice unavailable: {reason}"),
                OutputRole::Error,
            );
            return;
        }
        self.voice_continuous_active = true;
        self.voice_no_speech_count = 0;
        if persist_default {
            self.voice_continuous_default = true;
            if let Err(error) = persist_voice_preferences_to_config(Some(true), None, None) {
                self.push_output(
                    format!(
                        "Continuous voice enabled for this session, but config save failed: {error}"
                    ),
                    OutputRole::Error,
                );
            }
        }
        if self.voice_recording.is_some() {
            self.push_output(
                "Continuous voice is armed. Current capture will continue until it stops.",
                OutputRole::System,
            );
            return;
        }
        if self.is_processing {
            self.push_output(
                "Continuous voice is armed. Recording will start after the current response finishes.",
                OutputRole::System,
            );
            return;
        }
        if self.voice_playback_active {
            self.push_output(
                "Continuous voice is armed. Recording will resume after spoken playback finishes.",
                OutputRole::System,
            );
            return;
        }
        self.start_voice_recording(true);
    }

    fn stop_continuous_voice_session(&mut self, persist_default: bool) {
        self.voice_continuous_active = false;
        self.voice_no_speech_count = 0;
        if persist_default {
            self.voice_continuous_default = false;
            if let Err(error) = persist_voice_preferences_to_config(Some(false), None, None) {
                self.push_output(
                    format!("Continuous voice disabled for this session, but config save failed: {error}"),
                    OutputRole::Error,
                );
            }
        }
    }

    fn maybe_restart_continuous_voice_session(&mut self, reason: &str) {
        if !self.voice_continuous_active
            || self.voice_recording.is_some()
            || self.is_processing
            || self.voice_playback_active
        {
            return;
        }
        self.push_output(reason.to_string(), OutputRole::System);
        self.start_voice_recording(true);
    }

    fn note_empty_voice_capture(&mut self) {
        if !self.voice_continuous_active {
            return;
        }
        self.voice_no_speech_count = self.voice_no_speech_count.saturating_add(1);
        if self.voice_no_speech_count >= 3 {
            self.voice_continuous_active = false;
            self.voice_no_speech_count = 0;
            self.push_output(
                "Continuous voice stopped after 3 empty captures. Run `/voice continuous on` to resume.",
                OutputRole::System,
            );
            return;
        }
        self.maybe_restart_continuous_voice_session(
            "No clear speech detected. Listening again for continuous voice...",
        );
    }

    fn complete_voice_recording(
        &mut self,
        mut recording: VoiceRecordingSession,
        reason: VoiceRecordingFinishReason,
    ) {
        let duration = recording.started_at.elapsed();
        let should_stop_child = !matches!(
            reason,
            VoiceRecordingFinishReason::AutoSilence | VoiceRecordingFinishReason::RecorderExited
        );

        if should_stop_child {
            terminal_bell(2);
            if let Err(error) = stop_audio_recorder(&mut recording.child) {
                self.push_output(
                    format!("Failed to stop voice recording cleanly: {error}"),
                    OutputRole::Error,
                );
                let _ = std::fs::remove_file(&recording.output_path);
                return;
            }
        }

        let metadata = std::fs::metadata(&recording.output_path).ok();
        let size = metadata.as_ref().map(|meta| meta.len()).unwrap_or(0);
        if size == 0 {
            self.push_output(
                "Voice recording captured no audio. Check microphone permissions and input device.",
                OutputRole::Error,
            );
            let _ = std::fs::remove_file(&recording.output_path);
            self.note_empty_voice_capture();
            return;
        }

        let summary = match reason {
            VoiceRecordingFinishReason::ManualStop => "Voice recording saved",
            VoiceRecordingFinishReason::AutoSilence => "Voice recording auto-stopped after silence",
            VoiceRecordingFinishReason::RecorderExited => "Voice recording finished",
        };
        self.push_output(
            format!(
                "{summary} ({:.1}s, {} bytes) via {}. Transcribing...",
                duration.as_secs_f64(),
                size,
                describe_audio_recorder(recording.backend)
            ),
            OutputRole::System,
        );
        self.spawn_direct_transcription_with_cleanup(
            recording.output_path.display().to_string(),
            recording.submit_to_agent,
            true,
            VoiceTranscriptMeta {
                capture_duration_secs: duration.as_secs_f64(),
                continuous_session: recording.continuous_session,
            },
        );
    }

    fn poll_voice_recording_completion(&mut self) {
        let exited = self
            .voice_recording
            .as_mut()
            .and_then(|recording| recording.child.try_wait().ok())
            .flatten()
            .is_some();
        if !exited {
            return;
        }
        if let Some(recording) = self.voice_recording.take() {
            let reason = if matches!(
                recording.profile.stop_mode,
                VoiceRecordingStopMode::AutoSilence
            ) {
                VoiceRecordingFinishReason::AutoSilence
            } else {
                VoiceRecordingFinishReason::RecorderExited
            };
            self.complete_voice_recording(recording, reason);
        }
    }

    fn start_voice_recording(&mut self, submit_to_agent: bool) {
        let profile = self.voice_recording_profile(submit_to_agent);
        let backend =
            match voice_recording_ready(self.voice_input_device.as_deref(), profile.stop_mode) {
                Ok(backend) => backend,
                Err(reason) => {
                    if matches!(profile.stop_mode, VoiceRecordingStopMode::AutoSilence) {
                        self.voice_continuous_active = false;
                    }
                    self.push_output(
                        format!("Voice recording unavailable: {reason}"),
                        OutputRole::Error,
                    );
                    return;
                }
            };

        let recordings_dir = std::env::temp_dir().join("edgecrab_voice");
        if let Err(error) = std::fs::create_dir_all(&recordings_dir) {
            self.push_output(
                format!("Failed to create voice temp directory: {error}"),
                OutputRole::Error,
            );
            return;
        }
        let output_path = recordings_dir.join(format!(
            "recording_{}.wav",
            chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f")
        ));

        match spawn_audio_recorder(&output_path, self.voice_input_device.as_deref(), profile) {
            Ok((child, actual_backend)) => {
                terminal_bell(1);
                self.voice_recording = Some(VoiceRecordingSession {
                    child,
                    output_path: output_path.clone(),
                    submit_to_agent,
                    backend: actual_backend,
                    started_at: Instant::now(),
                    profile,
                    continuous_session: submit_to_agent && self.voice_continuous_active,
                });
                let action = if submit_to_agent && self.voice_continuous_active {
                    "recording continuous voice reply"
                } else if submit_to_agent {
                    "recording voice reply"
                } else {
                    "recording voice note"
                };
                let stop_hint = if matches!(profile.stop_mode, VoiceRecordingStopMode::AutoSilence)
                {
                    "Auto-stops on silence."
                } else {
                    "Press the voice key again to stop."
                };
                self.push_output(
                    format!(
                        "{action} via {}. {stop_hint}",
                        describe_audio_recorder(actual_backend),
                    ),
                    OutputRole::System,
                );
            }
            Err(error) => {
                if matches!(profile.stop_mode, VoiceRecordingStopMode::AutoSilence) {
                    self.voice_continuous_active = false;
                }
                let _ = std::fs::remove_file(&output_path);
                self.push_output(
                    format!(
                        "Failed to start microphone recorder ({}): {error}",
                        describe_audio_recorder(backend)
                    ),
                    OutputRole::Error,
                );
            }
        }
    }

    fn stop_voice_recording(&mut self) {
        let Some(recording) = self.voice_recording.take() else {
            self.push_output("Voice recording is not active.", OutputRole::System);
            return;
        };
        self.complete_voice_recording(recording, VoiceRecordingFinishReason::ManualStop);
    }

    fn abort_voice_recording(&mut self, reason: &str) {
        let Some(mut recording) = self.voice_recording.take() else {
            return;
        };
        let _ = stop_audio_recorder(&mut recording.child);
        let _ = std::fs::remove_file(&recording.output_path);
        self.push_output(reason.to_string(), OutputRole::System);
    }

    fn toggle_voice_recording(&mut self, submit_to_agent: bool) {
        if self.voice_recording.is_some() {
            if self.voice_continuous_active {
                self.stop_continuous_voice_session(false);
            }
            self.stop_voice_recording();
        } else {
            if submit_to_agent {
                self.voice_continuous_active = self.voice_continuous_default;
                self.voice_no_speech_count = 0;
            }
            self.start_voice_recording(submit_to_agent);
        }
    }

    fn personality_arg_hints(&self) -> Vec<(String, String)> {
        let mut hints = vec![
            (
                "show".to_string(),
                "Show the current personality state and available presets".to_string(),
            ),
            (
                "clear".to_string(),
                "Remove the active session personality overlay".to_string(),
            ),
        ];
        hints.extend(
            edgecrab_core::config::personality_catalog(&self.load_runtime_config())
                .into_iter()
                .map(|(name, preview)| (name, truncate_preview(&preview, 72))),
        );
        hints
    }

    fn handle_switch_skin(&mut self, name: String) {
        let name = name.trim().to_string();

        let engine = crate::skin_engine::SkinEngine::new();

        // No-arg (empty string) or "list" → open fuzzy skin browser overlay
        if name.is_empty() || name == "list" {
            self.open_skin_browser();
            return;
        }

        let available_names = engine.list_skins();
        if !available_names.contains(&name) {
            let available = available_names.join(", ");
            self.push_output(
                format!("Unknown skin '{name}'. Available: {available}"),
                OutputRole::System,
            );
            return;
        }

        {
            let skin = engine.get(&name);

            // Helper: parse hex string to ratatui Color
            fn hex_to_color(hex: &str) -> Option<ratatui::style::Color> {
                let hex = hex.trim_start_matches('#');
                if hex.len() != 6 {
                    return None;
                }
                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
                Some(ratatui::style::Color::Rgb(r, g, b))
            }

            let primary =
                hex_to_color(&skin.colors.ui_accent).unwrap_or(ratatui::style::Color::White);
            let secondary =
                hex_to_color(&skin.colors.banner_accent).unwrap_or(ratatui::style::Color::Cyan);
            let tool = hex_to_color(&skin.colors.ui_warn).unwrap_or(ratatui::style::Color::Yellow);
            let error = hex_to_color(&skin.colors.ui_error).unwrap_or(ratatui::style::Color::Red);
            let system = hex_to_color(&skin.colors.ui_label).unwrap_or(ratatui::style::Color::Gray);

            // Apply colors to the live Theme
            self.theme.input_border = ratatui::style::Style::default().fg(primary);
            self.theme.output_assistant = ratatui::style::Style::default().fg(secondary);
            self.theme.status_bar_model = ratatui::style::Style::default().fg(secondary);
            self.theme.output_tool = ratatui::style::Style::default()
                .fg(tool)
                .add_modifier(ratatui::style::Modifier::DIM);
            self.theme.output_error = ratatui::style::Style::default().fg(error);
            self.theme.output_system = ratatui::style::Style::default()
                .fg(system)
                .add_modifier(ratatui::style::Modifier::ITALIC);

            // Update prompt symbol if the skin defines one
            if !skin.branding.prompt_symbol.is_empty() && skin.branding.prompt_symbol != ">>> " {
                self.theme.prompt_symbol = skin.branding.prompt_symbol.clone();
            }

            // Apply per-tool emoji overrides from the named skin
            if !skin.tool_emojis.is_empty() {
                self.theme.tool_emojis = skin.tool_emojis.clone();
            }

            // Refresh textarea border to pick up the new border color
            self.textarea.set_block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(self.theme.input_border)
                    .title(format!(" {} ", self.theme.prompt_symbol.trim())),
            );
            self.textarea.set_style(self.theme.input_text);

            self.session_skin = Some(name.clone());
            self.push_output(format!("Skin switched to '{name}'."), OutputRole::System);
        }
    }

    fn handle_show_insights(&mut self, days: u32) {
        let Some(agent) = self.require_agent() else {
            return;
        };
        let snap = self
            .rt_handle
            .block_on(async { agent.session_snapshot().await });

        // ── Current session ────────────────────────────────────────────
        let cost = edgecrab_core::pricing::estimate_cost(
            &edgecrab_core::pricing::CanonicalUsage {
                input_tokens: snap.input_tokens,
                output_tokens: snap.output_tokens,
                cache_read_tokens: snap.cache_read_tokens,
                cache_write_tokens: snap.cache_write_tokens,
                reasoning_tokens: snap.reasoning_tokens,
            },
            &snap.model,
        );

        let mut text = String::from("── Current session ─────────────────────\n");
        text.push_str(&format!("  User turns:     {}\n", snap.user_turn_count));
        text.push_str(&format!("  Messages:       {}\n", snap.message_count));
        text.push_str(&format!("  API calls:      {}\n", snap.api_call_count));
        text.push_str(&format!(
            "  Current prompt: {}\n",
            snap.context_pressure_tokens()
        ));
        text.push_str(&format!("  Input tokens:   {}\n", snap.input_tokens));
        text.push_str(&format!("  Output tokens:  {}\n", snap.output_tokens));
        text.push_str(&format!("  Total tokens:   {}\n", snap.total_tokens()));
        if snap.cache_read_tokens > 0 {
            text.push_str(&format!("  Cache hit:      {}\n", snap.cache_read_tokens));
        }
        if snap.reasoning_tokens > 0 {
            text.push_str(&format!("  Reasoning:      {}\n", snap.reasoning_tokens));
        }
        text.push_str(&format!(
            "  Budget left:    {}/{}\n",
            snap.budget_remaining, snap.budget_max
        ));
        text.push_str(&format!(
            "  Est. cost:      ${:.4}\n",
            cost.amount_usd.unwrap_or(0.0)
        ));

        // ── Historical window ──────────────────────────────────────────
        let db_opt = self.rt_handle.block_on(async { agent.state_db().await });
        if let Some(db) = db_opt {
            match db.query_insights(days) {
                Ok(report) if report.overview.total_sessions > 0 => {
                    let ov = &report.overview;
                    text.push_str(&format!("\n── Last {days} days (all sessions) ─────────\n"));
                    text.push_str(&format!("  Sessions:       {}\n", ov.total_sessions));
                    text.push_str(&format!("  Messages:       {}\n", ov.total_messages));
                    text.push_str(&format!("  Tool calls:     {}\n", ov.total_tool_calls));
                    let hist_total = ov.total_input_tokens
                        + ov.total_output_tokens
                        + ov.total_cache_read_tokens
                        + ov.total_cache_write_tokens;
                    text.push_str(&format!("  Total tokens:   {hist_total}\n"));
                    if ov.total_cache_read_tokens > 0 {
                        text.push_str(&format!(
                            "  Cache hits:     {}\n",
                            ov.total_cache_read_tokens
                        ));
                    }
                    text.push_str(&format!(
                        "  Est. cost:      ${:.2}\n",
                        ov.estimated_total_cost_usd
                    ));

                    if !report.models.is_empty() {
                        text.push_str("\n  Models:\n");
                        for m in report.models.iter().take(5) {
                            text.push_str(&format!(
                                "    {:30} {:4} sessions  ${:.2}\n",
                                m.model, m.sessions, m.estimated_cost_usd
                            ));
                        }
                    }
                    if !report.top_tools.is_empty() {
                        text.push_str("\n  Top tools:\n");
                        for t in report.top_tools.iter().take(5) {
                            text.push_str(&format!("    {:30} {} calls\n", t.name, t.count));
                        }
                    }
                    if !report.daily_activity.is_empty() {
                        text.push_str("\n  Daily activity (last 14 days):\n");
                        let peak = report
                            .daily_activity
                            .iter()
                            .map(|d| d.sessions)
                            .max()
                            .unwrap_or(1)
                            .max(1);
                        for d in &report.daily_activity {
                            let bar_len = (d.sessions * 20 / peak) as usize;
                            let bar = "█".repeat(bar_len);
                            text.push_str(&format!("    {} {:>2} {}\n", d.day, d.sessions, bar));
                        }
                    }
                }
                Ok(_) => {
                    text.push_str(&format!(
                        "\n  No historical sessions found for the last {days} days.\n"
                    ));
                }
                Err(e) => {
                    text.push_str(&format!("\n  (Historical insights unavailable: {e})\n"));
                }
            }
        }

        self.push_output(text, OutputRole::System);
    }

    fn with_tui_suspended<T, F>(&mut self, action: F) -> anyhow::Result<T>
    where
        F: FnOnce(&mut io::Stdout) -> anyhow::Result<T>,
    {
        let mut stdout = io::stdout();
        suspend_tui_surface(&mut stdout, self.keyboard_enhancement_enabled)?;
        let action_result = action(&mut stdout);
        let _ = clear_terminal_surface(&mut stdout);
        let restore_result = restore_tui_surface(
            &mut stdout,
            self.keyboard_enhancement_enabled,
            self.mouse_capture_enabled,
        );
        self.pending_mouse_capture = None;
        self.needs_full_terminal_clear = true;
        self.needs_redraw = true;
        restore_result?;
        action_result
    }

    fn run_login_target_with_terminal_handoff(&mut self, raw_target: &str) {
        let target = raw_target.trim().to_string();
        let handle = self.rt_handle.clone();
        let result = self.with_tui_suspended(|stdout| {
            render_terminal_notice_panel(
                stdout,
                "EdgeCrab login",
                "Requesting a fresh login code...",
            )?;
            handle.block_on(async { crate::auth_cmd::login_target_capture(&target).await })
        });

        match result {
            Ok(report) => self.push_output(report, OutputRole::System),
            Err(err) => self.push_output(format!("Login failed: {err}"), OutputRole::Error),
        }
    }

    fn handle_copilot_auth(&mut self) {
        use edgequake_llm::providers::vscode::token::TokenManager;
        self.push_output("Checking for GitHub Copilot token...", OutputRole::System);
        let result = self.rt_handle.block_on(async {
            let manager = TokenManager::new()?;
            manager.import_vscode_token().await
        });
        match result {
            Ok(true) => {
                self.push_output(
                    "Copilot token imported from VS Code. You can now use copilot/... models.",
                    OutputRole::System,
                );
            }
            Ok(false) => {
                self.push_output(
                    "VS Code Copilot token not found (~/.config/github-copilot/hosts.json missing or no token).\nRun /login to start the GitHub device flow on a dedicated copy-friendly screen.",
                    OutputRole::System,
                );
            }
            Err(e) => {
                self.push_output(
                    format!("Copilot auth error: {e}\nRun /login to start the GitHub device flow on a dedicated copy-friendly screen."),
                    OutputRole::Error,
                );
            }
        }
    }

    fn handle_paste_clipboard(&mut self) {
        // Try text first
        match arboard::Clipboard::new().and_then(|mut cb| cb.get_text()) {
            Ok(text) if !text.is_empty() => {
                self.textarea.insert_str(&text);
                self.push_output(
                    format!("Pasted {} chars from clipboard.", text.len()),
                    OutputRole::System,
                );
                return;
            }
            _ => {}
        }

        // Try image clipboard
        match arboard::Clipboard::new().and_then(|mut cb| cb.get_image()) {
            Ok(img) => {
                // Save as PNG to ~/.edgecrab/images/
                let images_dir = edgecrab_core::config::edgecrab_home().join("images");
                if std::fs::create_dir_all(&images_dir).is_err() {
                    self.push_output("Failed to create images directory.", OutputRole::Error);
                    return;
                }
                let filename = format!(
                    "clipboard_{}.png",
                    chrono::Utc::now().format("%Y%m%d_%H%M%S")
                );
                let path = images_dir.join(&filename);

                // arboard ImageData → raw RGBA → encode as PNG
                let width = img.width;
                let height = img.height;
                let rgba_data = img.bytes.into_owned();

                // Write PNG using a minimal encoder
                match write_rgba_png(&path, &rgba_data, width as u32, height as u32) {
                    Ok(()) => {
                        self.pending_images.push(path.clone());
                        let count = self.pending_images.len();
                        self.push_output(
                            format!(
                                "📎 Image pasted from clipboard ({width}×{height}). {} image(s) attached — send a message to analyze.",
                                count
                            ),
                            OutputRole::System,
                        );
                    }
                    Err(e) => {
                        self.push_output(
                            format!("Failed to save clipboard image: {e}"),
                            OutputRole::Error,
                        );
                    }
                }
            }
            Err(_) => {
                self.push_output("Clipboard is empty (no text or image).", OutputRole::System);
            }
        }
    }

    // ─── Rollback / Checkpoint ──────────────────────────────────────
    //
    // WHY: hermes-agent wires /rollback to the checkpoint tool via the
    // agent. We mirror this by sending a natural-language request to the
    // agent which calls checkpoint(action="list") or checkpoint(action=
    // "restore", name=<name>) as appropriate.

    fn handle_rollback_checkpoint(&mut self, args: String) {
        let prompt = match args.trim() {
            "" | "list" => {
                "Please list all available checkpoints by calling the checkpoint tool with action='list'.".to_string()
            }
            name => {
                format!(
                    "Please restore the checkpoint named '{}' by calling the checkpoint tool \
                     with action='restore', name='{}'.",
                    name, name
                )
            }
        };
        let Some(agent) = self.require_agent() else {
            return;
        };
        let tx = self.response_tx.clone();
        self.push_output(
            if args.trim().is_empty() || args.trim() == "list" {
                "Listing checkpoints...".into()
            } else {
                format!("Restoring checkpoint '{}'...", args.trim())
            },
            OutputRole::System,
        );
        self.rt_handle.spawn(async move {
            match agent.chat(&prompt).await {
                Ok(resp) => {
                    let _ = tx.send(AgentResponse::Token(resp));
                    let _ = tx.send(AgentResponse::Done);
                }
                Err(e) => {
                    let _ = tx.send(AgentResponse::Error(format!("Rollback error: {e}")));
                }
            }
        });
    }

    // ─── MCP Reload ─────────────────────────────────────────────────
    //
    // WHY: `/reload-mcp` drops all cached subprocess connections so they
    // are re-established on the next mcp_list_tools / mcp_call_tool call.
    // This lets users restart or reconfigure MCP servers without
    // restarting EdgeCrab.

    fn handle_reload_mcp(&mut self) {
        edgecrab_tools::tools::mcp_client::reload_mcp_connections();
        self.push_output(
            "MCP server connections cleared.  They will be re-established on the next tool call.\n\
             (Configured via the mcp_servers section in ~/.edgecrab/config.yaml; legacy ~/.edgecrab/mcp.json is fallback-only.)",
            OutputRole::System,
        );
    }

    fn handle_mcp_command(&mut self, args: String) {
        let trimmed = args.trim();
        if trimmed.is_empty() {
            self.open_mcp_selector(None, false);
            return;
        }
        if trimmed.eq_ignore_ascii_case("help") {
            self.push_output(
                "Usage: /mcp            (browse configured servers + bundled official MCP catalog)\n\
                 /mcp list\n\
                 /mcp refresh\n\
                 /mcp search [query]  (search official MCP sources + registry)\n\
                 /mcp view <preset-or-server>\n\
                 /mcp install <preset> [--name <server-name>|name=<server-name>] [--path <directory>|path=<directory>]\n\
                 /mcp enable <server-name>\n\
                 /mcp disable <server-name>\n\
                 /mcp test [server-name]\n\
                 /mcp doctor [server-name]\n\
                 /mcp auth <server-name>\n\
                 /mcp login <server-name>\n\
                 /mcp remove <server-name>",
                OutputRole::System,
            );
            return;
        }

        let parts = match mcp_support::parse_inline_command_tokens(trimmed) {
            Ok(parts) => parts,
            Err(err) => {
                self.push_output(err, OutputRole::Error);
                return;
            }
        };

        match parts.first().map(String::as_str).unwrap_or_default() {
            "list" => match self.configured_mcp_servers(true) {
                Ok(servers) if !servers.is_empty() => {
                    let mut lines = Vec::new();
                    lines.push("Configured MCP servers:".to_string());
                    for server in servers {
                        let transport = format_mcp_transport(&server);
                        lines.push(format!(
                            "- {}  [{}]  {}",
                            server.name,
                            mcp_enabled_label(server.enabled),
                            transport
                        ));
                    }
                    self.open_report_overlay(
                        "Configured MCP Servers",
                        "Saved MCP transports and enablement state",
                        lines.join("\n"),
                    );
                }
                Ok(_) => self.push_output("No MCP servers configured.", OutputRole::System),
                Err(err) => self.push_output(err, OutputRole::Error),
            },
            "refresh" => {
                match self
                    .rt_handle
                    .block_on(crate::mcp_catalog::refresh_official_catalog())
                {
                    Ok(entries) => self.push_output(
                        format!(
                            "Refreshed official MCP catalog ({} entries).",
                            entries.len()
                        ),
                        OutputRole::System,
                    ),
                    Err(err) => self.push_output(
                        format!("Failed to refresh official MCP catalog: {err}"),
                        OutputRole::Error,
                    ),
                }
            }
            "search" => {
                let query = if parts.len() > 1 {
                    Some(parts[1..].join(" "))
                } else {
                    None
                };
                self.open_remote_mcp_selector(query.as_deref());
            }
            "view" => {
                let Some(preset_name) = parts.get(1).map(String::as_str) else {
                    self.push_output("Usage: /mcp view <preset-or-server>", OutputRole::System);
                    return;
                };
                if let Some(preset) = crate::mcp_catalog::find_preset(preset_name) {
                    self.open_report_overlay(
                        format!("MCP Preset · {}", preset.display_name),
                        "Built-in MCP preset details",
                        crate::mcp_catalog::render_preset_detail(preset),
                    );
                    return;
                }
                if let Some(entry) = self.rt_handle.block_on(
                    crate::mcp_catalog::find_official_catalog_entry_with_refresh(preset_name),
                ) {
                    self.open_report_overlay(
                        format!("Official MCP Entry · {}", entry.display_name),
                        "Official remote MCP catalog entry",
                        crate::mcp_catalog::render_official_catalog_entry(&entry),
                    );
                    return;
                }
                match self.configured_mcp_server(preset_name) {
                    Ok(server) => self.open_report_overlay(
                        format!("MCP Server · {}", server.name),
                        "Configured local MCP server detail",
                        mcp_support::render_configured_server_detail(&server),
                    ),
                    Err(err) => self.push_output(err, OutputRole::Error),
                }
            }
            "enable" | "activate" => {
                let Some(name) = parts.get(1).map(String::as_str) else {
                    self.push_output("Usage: /mcp enable <server-name>", OutputRole::System);
                    return;
                };
                self.set_mcp_server_enabled(name, true);
            }
            "disable" | "deactivate" => {
                let Some(name) = parts.get(1).map(String::as_str) else {
                    self.push_output("Usage: /mcp disable <server-name>", OutputRole::System);
                    return;
                };
                self.set_mcp_server_enabled(name, false);
            }
            "install" => {
                let Some(preset_name) = parts.get(1).map(String::as_str) else {
                    self.push_output(
                        "Usage: /mcp install <preset> [--name <server-name>|name=<server-name>] [--path <directory>|path=<directory>]",
                        OutputRole::System,
                    );
                    return;
                };
                let (path, remaining) = match mcp_support::parse_named_option(&parts[2..], "path") {
                    Ok(parsed) => parsed,
                    Err(err) => {
                        self.push_output(err, OutputRole::Error);
                        return;
                    }
                };
                let (name, remaining) = match mcp_support::parse_named_option(&remaining, "name") {
                    Ok(parsed) => parsed,
                    Err(err) => {
                        self.push_output(err, OutputRole::Error);
                        return;
                    }
                };
                if !remaining.is_empty() {
                    self.push_output(
                        format!("Unexpected MCP install arguments: {}", remaining.join(" ")),
                        OutputRole::Error,
                    );
                    return;
                }
                let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
                let mut config = self.load_runtime_config();
                match crate::mcp_catalog::install_preset(
                    &mut config,
                    preset_name,
                    name.as_deref(),
                    path.as_deref().map(std::path::Path::new),
                    &cwd,
                ) {
                    Ok(installed) => match config.save() {
                        Ok(()) => {
                            edgecrab_tools::tools::mcp_client::reload_mcp_connections();
                            let mut message =
                                format!("Configured MCP server '{}'.", installed.name);
                            if !installed.missing_env.is_empty() {
                                message.push_str(&format!(
                                    "\nMissing env vars: {}",
                                    installed.missing_env.join(", ")
                                ));
                            }
                            message.push_str(&format!(
                                "\nRun `/mcp doctor {}` to verify connectivity and config health.",
                                installed.name
                            ));
                            self.push_output(message, OutputRole::System);
                        }
                        Err(err) => self.push_output(
                            format!(
                                "Preset installed for this session, but config save failed: {err}"
                            ),
                            OutputRole::Error,
                        ),
                    },
                    Err(err) => {
                        self.push_output(format!("MCP install failed: {err}"), OutputRole::Error)
                    }
                }
            }
            "test" => {
                let name = parts.get(1).cloned();
                let tx = self.response_tx.clone();
                self.rt_handle.spawn(async move {
                    let targets = if let Some(name) = name {
                        vec![name]
                    } else {
                        match edgecrab_tools::tools::mcp_client::configured_servers() {
                            Ok(servers) => servers.into_iter().map(|server| server.name).collect(),
                            Err(err) => {
                                let _ = tx.send(AgentResponse::Notice(format!(
                                    "Failed to read MCP configuration: {err}"
                                )));
                                return;
                            }
                        }
                    };
                    if targets.is_empty() {
                        let _ = tx.send(AgentResponse::Notice("No MCP servers configured.".into()));
                        return;
                    }
                    for target in targets {
                        match edgecrab_tools::tools::mcp_client::probe_configured_server(&target)
                            .await
                        {
                            Ok(result) => {
                                let _ = tx.send(AgentResponse::Notice(format!(
                                    "{}  ok  transport={} tools={}",
                                    result.server_name, result.transport, result.tool_count
                                )));
                            }
                            Err(err) => {
                                let _ = tx.send(AgentResponse::Notice(format!(
                                    "{}  fail  {}",
                                    target, err
                                )));
                            }
                        }
                    }
                });
            }
            "doctor" => {
                let name = parts.get(1).cloned();
                let tx = self.response_tx.clone();
                self.rt_handle.spawn(async move {
                    match mcp_support::render_mcp_doctor_report(name.as_deref()).await {
                        Ok(report) => {
                            let _ = tx.send(AgentResponse::Notice(report));
                        }
                        Err(err) => {
                            let _ =
                                tx.send(AgentResponse::Notice(format!("MCP doctor failed: {err}")));
                        }
                    }
                });
            }
            "auth" => {
                let Some(name) = parts.get(1) else {
                    self.push_output("Usage: /mcp auth <server-name>", OutputRole::System);
                    return;
                };
                match mcp_support::render_mcp_auth_guide(name) {
                    Ok(report) => self.open_report_overlay(
                        format!("MCP Auth · {name}"),
                        "Authentication and token guidance",
                        report,
                    ),
                    Err(err) => {
                        self.push_output(format!("MCP auth failed: {err}"), OutputRole::Error)
                    }
                }
            }
            "login" => {
                let Some(name) = parts.get(1).cloned() else {
                    self.push_output("Usage: /mcp login <server-name>", OutputRole::System);
                    return;
                };
                let tx = self.response_tx.clone();
                self.rt_handle.spawn(async move {
                    let summary = crate::mcp_oauth::login_mcp_server(&name, |line| {
                        let _ = tx.send(AgentResponse::Notice(line));
                    })
                    .await;
                    match summary {
                        Ok(summary) => {
                            let _ = tx.send(AgentResponse::Notice(summary));
                        }
                        Err(err) => {
                            let _ = tx.send(AgentResponse::Notice(format!(
                                "MCP OAuth login failed: {err}"
                            )));
                        }
                    }
                });
            }
            "remove" | "uninstall" | "rm" => {
                let Some(name) = parts.get(1).map(String::as_str) else {
                    self.push_output("Usage: /mcp remove <server-name>", OutputRole::System);
                    return;
                };
                let mut config = self.load_runtime_config();
                if config.mcp_servers.remove(name).is_none() {
                    self.push_output(format!("Unknown MCP server '{name}'."), OutputRole::Error);
                    return;
                }
                match config.save() {
                    Ok(()) => {
                        edgecrab_tools::tools::mcp_client::remove_mcp_token(name);
                        edgecrab_tools::tools::mcp_client::reload_mcp_connections();
                        self.push_output(
                            format!("Removed MCP server '{name}'."),
                            OutputRole::System,
                        );
                    }
                    Err(err) => self.push_output(
                        format!("Failed to save config after removing MCP server: {err}"),
                        OutputRole::Error,
                    ),
                }
            }
            other => self.push_output(
                format!("Unknown /mcp action '{other}'. Use `/mcp help`."),
                OutputRole::Error,
            ),
        }
    }

    // ─── Voice Mode ─────────────────────────────────────────────────
    //
    // WHY mirrors hermes-agent's /voice: When voice mode is enabled,
    // the agent's text response is spoken via the `text_to_speech` tool
    // after each turn.  This provides audio feedback without blocking
    // the streaming UI.
    //
    // EdgeCrab now supports deterministic local microphone capture in the TUI
    // using controlled recorder backends plus the existing STT/TTS tools.

    fn handle_voice_mode(&mut self, args: String) {
        let trimmed = args.trim();
        let runtime_config = self.load_runtime_config();
        // Check for "/voice tts <text>" — immediate TTS of arbitrary text
        if let Some(text) = trimmed
            .strip_prefix("tts ")
            .or_else(|| trimmed.strip_prefix("tts\t"))
        {
            let player = match voice_readback_ready() {
                Ok(player) => player,
                Err(reason) => {
                    self.push_output(format!("Voice unavailable: {reason}"), OutputRole::Error);
                    return;
                }
            };
            let text = text.trim();
            if text.is_empty() {
                self.push_output("Usage: /voice tts <text to speak>", OutputRole::System);
                return;
            }
            let text_owned = text.to_string();
            self.push_output(
                format!(
                    "Speaking via {}: {}",
                    player.program,
                    edgecrab_core::safe_truncate(&text_owned, 80)
                ),
                OutputRole::System,
            );
            self.spawn_direct_tts(text_owned, true);
            return;
        }

        if let Some(file_path) = trimmed
            .strip_prefix("transcribe ")
            .or_else(|| trimmed.strip_prefix("transcribe\t"))
        {
            let file_path = file_path.trim();
            if file_path.is_empty() {
                self.push_output(
                    "Usage: /voice transcribe <audio-file-path>",
                    OutputRole::System,
                );
                return;
            }
            self.push_output(
                format!("Transcribing audio: {}", file_path),
                OutputRole::System,
            );
            self.spawn_direct_transcription(file_path.to_string(), false);
            return;
        }

        if let Some(file_path) = trimmed
            .strip_prefix("reply ")
            .or_else(|| trimmed.strip_prefix("reply\t"))
        {
            let file_path = file_path.trim();
            if file_path.is_empty() {
                self.push_output("Usage: /voice reply <audio-file-path>", OutputRole::System);
                return;
            }
            self.push_output(
                format!("Transcribing and submitting audio reply: {}", file_path),
                OutputRole::System,
            );
            self.spawn_direct_transcription(file_path.to_string(), true);
            return;
        }

        match trimmed {
            "record" => {
                self.toggle_voice_recording(false);
                return;
            }
            "talk" => {
                self.toggle_voice_recording(true);
                return;
            }
            "stop" => {
                self.stop_continuous_voice_session(false);
                if self.voice_recording.is_some() {
                    self.stop_voice_recording();
                } else {
                    self.push_output("Voice capture is already idle.", OutputRole::System);
                }
                return;
            }
            "doctor" => {
                let recorder = preferred_audio_recorder();
                let recorder_text = recorder
                    .map(describe_audio_recorder)
                    .unwrap_or("unavailable");
                let auto_stop = recorder
                    .map(recorder_auto_silence_support_message)
                    .unwrap_or("no supported recorder installed");
                let input_device =
                    self.voice_input_device
                        .as_deref()
                        .unwrap_or(if cfg!(target_os = "windows") {
                            "not configured"
                        } else {
                            "default"
                        });
                let permission_hint = if cfg!(target_os = "macos") {
                    "macOS: grant microphone access to your terminal app in System Settings > Privacy & Security > Microphone."
                } else if cfg!(target_os = "windows") {
                    "Windows: enable microphone access in Settings > Privacy & security > Microphone and set `voice.input_device` to an ffmpeg dshow device name."
                } else {
                    "Linux: confirm your user can access PulseAudio/PipeWire/ALSA input devices and that the selected recorder exists on PATH."
                };
                self.push_output(
                    format!(
                        "Voice doctor\n\
                         TTS: {}\n\
                         STT: {}\n\
                         Recorder: {recorder_text}\n\
                         Recorder input: {input_device}\n\
                         Continuous capture: {auto_stop}\n\
                         Playback: {}\n\
                         {permission_hint}",
                        if TextToSpeechTool.is_available() {
                            "available"
                        } else {
                            "unavailable"
                        },
                        if TranscribeAudioTool.is_available() {
                            "available"
                        } else {
                            "unavailable"
                        },
                        preferred_audio_player()
                            .map(|player| player.program)
                            .unwrap_or("unavailable"),
                    ),
                    OutputRole::System,
                );
                return;
            }
            _ => {}
        }

        if let Some(subcommand) = trimmed
            .strip_prefix("continuous ")
            .or_else(|| trimmed.strip_prefix("continuous\t"))
        {
            match subcommand.trim() {
                "on" => {
                    self.begin_continuous_voice_session(true);
                }
                "off" => {
                    self.stop_continuous_voice_session(true);
                    if self.voice_recording.is_some() {
                        self.abort_voice_recording("Continuous voice disabled.");
                    } else {
                        self.push_output(
                            "Continuous voice disabled for future captures.",
                            OutputRole::System,
                        );
                    }
                }
                "status" | "" => {
                    self.push_output(
                        format!(
                            "Continuous voice default: {}\nCurrent session: {}\nHallucination filter: {}",
                            if self.voice_continuous_default {
                                "ON"
                            } else {
                                "OFF"
                            },
                            if self.voice_continuous_active {
                                "ACTIVE"
                            } else {
                                "idle"
                            },
                            if self.voice_hallucination_filter {
                                "ON"
                            } else {
                                "OFF"
                            }
                        ),
                        OutputRole::System,
                    );
                }
                other => self.push_output(
                    format!("Usage: /voice continuous <on|off|status> (got `{other}`)"),
                    OutputRole::System,
                ),
            }
            return;
        }

        match trimmed {
            "on" => {
                let player = match voice_readback_ready() {
                    Ok(player) => player,
                    Err(reason) => {
                        self.push_output(
                            format!("Voice mode remains OFF: {reason}"),
                            OutputRole::Error,
                        );
                        return;
                    }
                };
                self.voice_mode_enabled = true;
                let persisted = persist_voice_enabled_to_config(true).is_ok();
                self.push_output(
                    format!(
                        "Voice mode: ON — agent responses will be read aloud via TTS.\n\
                         Backend: {}  Voice: {}\n\
                         Playback: {}\n\
                         Persistent default: {}",
                        runtime_config.tts.provider,
                        runtime_config.tts.voice,
                        player.program,
                        if persisted { "saved" } else { "not saved" }
                    ),
                    OutputRole::System,
                );
            }
            "tts" => {
                let player = match voice_readback_ready() {
                    Ok(player) => player,
                    Err(reason) => {
                        self.push_output(
                            format!("Auto-TTS remains OFF: {reason}"),
                            OutputRole::Error,
                        );
                        return;
                    }
                };
                self.voice_mode_enabled = true;
                let persisted = persist_voice_enabled_to_config(true).is_ok();
                self.push_output(
                    format!(
                        "Auto-TTS enabled.\n\
                         All assistant replies will be spoken in this CLI session.\n\
                         Backend: {}  Voice: {}\n\
                         Playback: {}\n\
                         Persistent default: {}",
                        runtime_config.tts.provider,
                        runtime_config.tts.voice,
                        player.program,
                        if persisted { "saved" } else { "not saved" }
                    ),
                    OutputRole::System,
                );
            }
            "off" => {
                self.voice_mode_enabled = false;
                let persisted = persist_voice_enabled_to_config(false).is_ok();
                self.push_output(
                    format!(
                        "Voice mode: OFF\nPersistent default: {}",
                        if persisted { "saved" } else { "not saved" }
                    ),
                    OutputRole::System,
                );
            }
            "status" | "" => {
                let status = if self.voice_mode_enabled { "ON" } else { "OFF" };
                let playback = preferred_audio_player()
                    .map(|player| player.program.to_string())
                    .unwrap_or_else(|| "unavailable".into());
                let recorder = preferred_audio_recorder()
                    .map(describe_audio_recorder)
                    .unwrap_or("unavailable");
                let auto_stop_support = preferred_audio_recorder()
                    .map(recorder_auto_silence_support_message)
                    .unwrap_or("no supported recorder installed");
                let input_device =
                    self.voice_input_device
                        .as_deref()
                        .unwrap_or(if cfg!(target_os = "windows") {
                            "not configured"
                        } else {
                            "default"
                        });
                let tts_status = if TextToSpeechTool.is_available() {
                    "available"
                } else {
                    "unavailable"
                };
                let stt_status = if TranscribeAudioTool.is_available() {
                    "available"
                } else {
                    "unavailable"
                };
                let recording_state = if self.voice_recording.is_some() {
                    "recording"
                } else {
                    "idle"
                };
                let continuous_state = if self.voice_continuous_active {
                    "active"
                } else if self.voice_continuous_default {
                    "armed by default"
                } else {
                    "off"
                };
                self.push_output(
                    format!(
                        "Voice mode: {status}\n\
                         Default backend: {}  Voice: {}\n\
                         TTS backend: {tts_status}\n\
                         STT backend: {stt_status}\n\
                         Playback: {playback}\n\
                         Playback active: {}\n\
                         Recorder: {recorder}\n\
                         Continuous capture: {continuous_state}\n\
                         Continuous backend support: {auto_stop_support}\n\
                         Hallucination filter: {}\n\
                         Recorder input: {input_device}\n\
                         Push-to-talk key: {push_to_talk_key} ({recording_state})\n\
                         /voice on           — enable spoken readback\n\
                         /voice tts          — alias for always-on spoken readback\n\
                         /voice off          — disable TTS readback\n\
                         /voice status       — show voice mode state\n\
                         /voice continuous on|off|status — manage hands-free voice capture\n\
                         /voice stop         — stop the active recording or loop\n\
                         /voice doctor       — show recorder + permission diagnostics\n\
                         /voice record       — record audio and transcribe it\n\
                         /voice talk         — record audio and submit transcript\n\
                         /voice tts <text>   — speak text immediately\n\
                         /voice transcribe <audio-file> — transcribe a local audio file\n\
                         /voice reply <audio-file>      — transcribe and submit it as your next prompt",
                        runtime_config.tts.provider,
                        runtime_config.tts.voice,
                        if self.voice_playback_active { "yes" } else { "no" },
                        if self.voice_hallucination_filter {
                            "ON"
                        } else {
                            "OFF"
                        },
                        push_to_talk_key = self.voice_push_to_talk_key,
                    ),
                    OutputRole::System,
                );
            }
            _ => {
                self.push_output(
                    "Usage: /voice <on|off|tts|status|continuous <on|off|status>|stop|doctor|record|talk|tts <text>|transcribe <file>|reply <file>>",
                    OutputRole::System,
                );
            }
        }
    }

    /// Manage MCP OAuth Bearer tokens stored at ~/.edgecrab/mcp-tokens/.
    ///
    /// Subcommands:
    ///   set <server> <token>  — store an access token for an HTTP MCP server
    ///   set-refresh <server> <token>  — store a refresh token for OAuth flows
    ///   status <server>       — show cached token state for one server
    ///   remove <server>       — delete stored tokens for a server
    ///   list                  — list servers with stored tokens
    fn handle_mcp_token(&mut self, args: String) {
        use edgecrab_tools::tools::mcp_client::{
            read_mcp_token_status, remove_mcp_token, write_mcp_refresh_token, write_mcp_token,
        };

        let parts = match mcp_support::parse_inline_command_tokens(args.trim()) {
            Ok(parts) => parts,
            Err(err) => {
                self.push_output(err, OutputRole::Error);
                return;
            }
        };

        if parts.is_empty() || matches!(parts.first().map(String::as_str), Some("list")) {
            // List servers that have a stored token by reading the tokens dir
            let dir = edgecrab_core::edgecrab_home().join("mcp-tokens");
            {
                if dir.is_dir() {
                    let entries: Vec<String> = std::fs::read_dir(&dir)
                        .ok()
                        .into_iter()
                        .flatten()
                        .filter_map(|e| e.ok())
                        .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
                        .filter_map(|e| {
                            e.path()
                                .file_stem()
                                .map(|s| s.to_string_lossy().into_owned())
                        })
                        .collect();
                    if entries.is_empty() {
                        self.push_output(
                            "No MCP OAuth tokens stored.\n\
                             Use: /mcp-token set <server> <bearer-token>",
                            OutputRole::System,
                        );
                    } else {
                        let mut out = String::from("Stored MCP OAuth tokens:\n");
                        for srv in &entries {
                            if let Some(status) = read_mcp_token_status(srv) {
                                out.push_str(&format!(
                                    "  {srv}  access={} refresh={}\n",
                                    if status.has_access_token { "yes" } else { "no" },
                                    if status.has_refresh_token {
                                        "yes"
                                    } else {
                                        "no"
                                    },
                                ));
                            } else {
                                out.push_str(&format!("  {srv}\n"));
                            }
                        }
                        out.push_str("\nUsage:\n");
                        out.push_str(
                            "  /mcp-token set <server> <token>          — store access token\n",
                        );
                        out.push_str(
                            "  /mcp-token set-refresh <server> <token>  — store refresh token\n",
                        );
                        out.push_str(
                            "  /mcp-token status <server>               — show cached token state\n",
                        );
                        out.push_str("  /mcp-token remove <server>               — remove token\n");
                        self.push_output(out, OutputRole::System);
                    }
                    return;
                }
            }
            self.push_output(
                "No MCP OAuth tokens stored.\n\
                 Use: /mcp-token set <server> <bearer-token>",
                OutputRole::System,
            );
            return;
        }

        match parts.as_slice() {
            [action, server, token] if action == "set" => match write_mcp_token(server, token) {
                Ok(()) => {
                    self.push_output(
                        format!("MCP access token stored for server '{server}'."),
                        OutputRole::System,
                    );
                }
                Err(e) => {
                    self.push_output(format!("Failed to store MCP token: {e}"), OutputRole::Error);
                }
            },
            [action, server, token] if action == "set-refresh" => {
                match write_mcp_refresh_token(server, token) {
                    Ok(()) => self.push_output(
                        format!("MCP refresh token stored for server '{server}'."),
                        OutputRole::System,
                    ),
                    Err(e) => self.push_output(
                        format!("Failed to store MCP refresh token: {e}"),
                        OutputRole::Error,
                    ),
                }
            }
            [action, server] if action == "status" => {
                if let Some(status) = read_mcp_token_status(server) {
                    let expiry = status
                        .expires_at_epoch_secs
                        .map(|value| value.to_string())
                        .unwrap_or_else(|| "none".into());
                    self.push_output(
                        format!(
                            "MCP token cache for '{server}': access_token={} refresh_token={} expires_at={expiry}",
                            if status.has_access_token { "yes" } else { "no" },
                            if status.has_refresh_token { "yes" } else { "no" },
                        ),
                        OutputRole::System,
                    );
                } else {
                    self.push_output(
                        format!("No cached MCP token state found for server '{server}'."),
                        OutputRole::System,
                    );
                }
            }
            [action, server] if action == "remove" => {
                remove_mcp_token(server);
                self.push_output(
                    format!("MCP token removed for server '{server}'."),
                    OutputRole::System,
                );
            }
            _ => {
                self.push_output(
                    "Usage:\n\
                     /mcp-token set <server> <token>          — store access token\n\
                     /mcp-token set-refresh <server> <token>  — store refresh token\n\
                     /mcp-token status <server>               — show cached token state\n\
                     /mcp-token remove <server>               — remove stored token\n\
                     /mcp-token list                          — list servers with tokens",
                    OutputRole::System,
                );
            }
        }
    }

    // ─── Browser CDP connection ─────────────────────────────────────

    /// Handle `/browser connect|disconnect|status|tabs|recording` — manage live Chrome CDP.
    ///
    /// Exceeds hermes-agent in:
    /// - Port TCP reachability check before accepting connect
    /// - Auto-launch Chrome if not running (macOS/Linux multi-binary scan)
    /// - Startup wait loop (up to 5 s polling)
    /// - Manual instruction fallback with OS-specific command
    /// - Agent context injection via prompt_queue (model sees the switch)
    /// - `/browser status` shows live reachability + Chrome version info
    /// - `/browser tabs` lists all open Chrome tabs with title + URL
    /// - `/browser recording on|off` toggles session recording at runtime
    fn handle_browser_command(&mut self, args: String) {
        use edgecrab_tools::tools::browser::{
            auto_detect_running_chrome_cdp, cdp_override_status, chrome_launch_command,
            clear_cdp_override, close_all_sessions, get_chrome_info, get_recording_override,
            launch_chrome_for_debugging, list_cdp_tabs, probe_cdp_port, set_cdp_override,
            set_recording_override, wait_for_cdp_ready,
        };

        let sub = args.trim().to_lowercase();
        let sub = if sub.is_empty() {
            "status".to_string()
        } else {
            sub
        };

        // ── connect ──────────────────────────────────────────────────────────
        if sub.starts_with("connect") {
            // Parse optional CDP URL: /browser connect ws://host:port
            let url_arg = args
                .trim()
                .strip_prefix("connect")
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .unwrap_or("http://localhost:9222");

            // Extract host/port for probing
            let (probe_host, probe_port) = {
                let stripped = url_arg
                    .trim_start_matches("ws://")
                    .trim_start_matches("wss://")
                    .trim_start_matches("http://")
                    .trim_start_matches("https://");
                let host_port = stripped.split('/').next().unwrap_or(stripped);
                if let Some((h, p)) = host_port.rsplit_once(':') {
                    let port: u16 = p.parse().unwrap_or(9222);
                    (h.to_string(), port)
                } else if let Ok(p) = host_port.parse::<u16>() {
                    ("127.0.0.1".to_string(), p)
                } else {
                    ("127.0.0.1".to_string(), 9222u16)
                }
            };
            let is_default_endpoint =
                probe_port == 9222 && (probe_host == "127.0.0.1" || probe_host == "localhost");

            // Close existing sessions before switching backends
            close_all_sessions();

            // Step 1: check if Chrome is already listening
            let already_up = self
                .rt_handle
                .block_on(probe_cdp_port(&probe_host, probe_port));

            let chrome_ready = if already_up {
                self.push_output(
                    format!("  ✓ Chrome is already listening on port {probe_port}"),
                    OutputRole::System,
                );
                true
            } else if is_default_endpoint {
                // Step 2a: auto-detect an already-running Chrome with CDP
                //          (reads DevToolsActivePort files + port scan)
                let detected = self.rt_handle.block_on(auto_detect_running_chrome_cdp());

                if let Some(ref found_ep) = detected
                    && found_ep.port != probe_port
                {
                    self.push_output(
                        format!(
                            "  ℹ Detected running Chrome with CDP on port {} \
                                 (you requested {probe_port}).\n\
                                 Tip: use `/browser connect {}` to attach to it.",
                            found_ep.port, found_ep.port
                        ),
                        OutputRole::System,
                    );
                }

                // Step 2b: try auto-launching a new headless Chrome on the requested port
                self.push_output(
                    format!(
                        "  Chrome isn't running with remote debugging on port {probe_port} — \
                         attempting to launch..."
                    ),
                    OutputRole::System,
                );
                let launched = launch_chrome_for_debugging(probe_port);
                if launched {
                    // Step 3: wait up to 5 s for the port to come up
                    let ready =
                        self.rt_handle
                            .block_on(wait_for_cdp_ready(&probe_host, probe_port, 5));
                    if ready {
                        self.push_output(
                            format!("  ✓ Chrome launched and listening on port {probe_port}"),
                            OutputRole::System,
                        );
                        true
                    } else {
                        self.push_output(
                            format!(
                                "  ⚠ Chrome launched but port {probe_port} isn't responding yet.\n\
                                 You may need to close existing Chrome windows first and retry."
                            ),
                            OutputRole::System,
                        );
                        false
                    }
                } else {
                    // Auto-launch failed — show manual instructions
                    let cmd = chrome_launch_command(probe_port);
                    self.push_output(
                        format!(
                            "  ⚠ Could not auto-launch Chrome.\n\
                             Start Chrome manually with CDP enabled:\n\n\
                             {cmd}"
                        ),
                        OutputRole::System,
                    );
                    false
                }
            } else {
                // Custom endpoint not reachable
                self.push_output(
                    format!("  ⚠ Port {probe_port} is not reachable at {probe_host}:{probe_port}"),
                    OutputRole::System,
                );
                false
            };

            match set_cdp_override(url_arg) {
                Ok(ep) => {
                    let reachable_line = if chrome_ready {
                        "  Status: ✓ reachable"
                    } else {
                        "  Status: ⚠ not yet reachable (connect again once Chrome is running)"
                    };
                    self.push_output(
                        format!(
                            "\n🌐 Browser connected to live Chrome via CDP\n\
                             Endpoint: {}:{}\n\
                             {reachable_line}\n\n\
                             All browser tools (browser_navigate, browser_click, etc.) \
                             now operate on your live Chrome instance.\n\
                             Use /browser disconnect to revert to headless mode.",
                            ep.host, ep.port
                        ),
                        OutputRole::System,
                    );

                    // Two-part context injection so the model is aware of
                    // the connection on its very next turn:
                    //
                    // 1. Append to the system prompt (affects tool-use guidance)
                    // 2. Inject a synthetic assistant message into history so
                    //    the model's own "voice" acknowledges the change and
                    //    overcomes any prior turns where it claimed no access.
                    if let Some(agent) = self.agent.clone() {
                        let system_note = "## Live Chrome Browser Connected\n\
                             The user has connected browser tools to their running Chrome \
                             browser via Chrome DevTools Protocol (CDP). From this point:\n\
                             - browser_navigate, browser_snapshot, browser_click, browser_type, \
                             and all other browser_* tools now control their REAL browser.\n\
                             - The browser already has tabs open with the user's cookies and \
                             logged-in sessions.\n\
                             - ALWAYS call browser_snapshot() first to see what page is currently \
                             open and get the actual URL before extracting or summarising content.\n\
                             - Do NOT use placeholder URLs like CURRENT_TAB — snapshot the page \
                             to get the real URL.\n\
                             - Be careful: your actions affect their real browser — don't close \
                             tabs or navigate away from important pages without asking.";
                        self.rt_handle
                            .block_on(agent.append_to_system_prompt(system_note));
                        // Injecting an assistant acknowledgment into message history
                        // ensures the model won't revert to claiming it has no
                        // browser access, even if it said so in a previous turn.
                        self.rt_handle.block_on(agent.inject_assistant_context(
                            "[Context update] My browser tools are now connected to the \
                             user's live Chrome browser via CDP. I have full access to \
                             browser_navigate, browser_snapshot, browser_click, browser_type, \
                             browser_scroll, browser_press, browser_back, browser_vision, \
                             browser_console, browser_get_images, and browser_close — and they \
                             all operate on the user's real browser with their existing sessions \
                             and cookies. I'll call browser_snapshot() before any action to see \
                             what page is currently open.",
                        ));
                    }
                }
                Err(e) => {
                    self.push_output(format!("Failed to connect: {e}"), OutputRole::Error);
                }
            }

        // ── disconnect ────────────────────────────────────────────────────────
        } else if sub == "disconnect" {
            if cdp_override_status().is_some() || std::env::var("BROWSER_CDP_URL").is_ok() {
                clear_cdp_override();
                // SAFETY: single-threaded TUI; no other thread reads this env var concurrently.
                #[allow(unsafe_code)]
                unsafe {
                    std::env::remove_var("BROWSER_CDP_URL")
                };
                close_all_sessions();
                self.push_output(
                    "🌐 Browser disconnected from live Chrome\n\
                     Browser tools reverted to default mode (local headless Chromium).",
                    OutputRole::System,
                );
                if let Some(agent) = self.agent.clone() {
                    let disconnect_note = "## Live Chrome Browser Disconnected\n\
                             The user has disconnected the browser from live Chrome. \
                             Browser tools are back to default headless Chromium mode.";
                    self.rt_handle
                        .block_on(agent.append_to_system_prompt(disconnect_note));
                    self.rt_handle.block_on(agent.inject_assistant_context(
                        "[Context update] The browser connection to live Chrome has been \
                             closed. Browser tools now use the default headless Chromium mode.",
                    ));
                }
            } else {
                self.push_output(
                    "Browser is not connected to live Chrome (already using default mode).",
                    OutputRole::System,
                );
            }

        // ── status ────────────────────────────────────────────────────────────
        } else if sub == "status" {
            let mut lines = String::new();

            if let Some(endpoint) = cdp_override_status() {
                // Check live reachability and fetch Chrome version info
                let (reachable, info) = self.rt_handle.block_on(async {
                    let ep_parts: Vec<&str> = endpoint.splitn(2, ':').collect();
                    let host = ep_parts.first().copied().unwrap_or("127.0.0.1");
                    let port: u16 = ep_parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(9222);
                    let ok = probe_cdp_port(host, port).await;
                    let info = if ok { get_chrome_info().await } else { None };
                    (ok, info)
                });

                lines.push_str("🌐 Browser: connected to live Chrome via CDP\n");
                lines.push_str(&format!("   Endpoint: {endpoint}\n"));
                if reachable {
                    lines.push_str("   Status:   ✓ reachable\n");
                    if let Some(ref ci) = info {
                        if !ci.browser.is_empty() {
                            lines.push_str(&format!("   Browser:  {}\n", ci.browser));
                        }
                        if !ci.protocol_version.is_empty() {
                            lines.push_str(&format!("   CDP:      v{}\n", ci.protocol_version));
                        }
                    }
                } else {
                    lines.push_str("   Status:   ⚠ not reachable (Chrome may have closed)\n");
                }
            } else if let Ok(url) = std::env::var("BROWSER_CDP_URL") {
                lines.push_str("🌐 Browser: CDP override via BROWSER_CDP_URL env var\n");
                lines.push_str(&format!("   Endpoint: {url}\n"));
            } else {
                // Default headless mode — show what binary is available
                lines.push_str("🌐 Browser: local headless Chrome/Chromium\n");
                // Try to get info from default endpoint (may not be running)
                let info = self.rt_handle.block_on(get_chrome_info());
                if let Some(ci) = info
                    && !ci.browser.is_empty()
                {
                    lines.push_str(&format!("   Browser:  {}\n", ci.browser));
                }

                // Auto-detect any already-running Chrome with CDP
                let detected = self.rt_handle.block_on(auto_detect_running_chrome_cdp());
                if let Some(ref ep) = detected {
                    lines.push_str(&format!(
                        "   ℹ Detected running Chrome with CDP on port {} \
                         — run `/browser connect {}` to attach to it.\n",
                        ep.port, ep.port
                    ));
                }
            }

            // Show recording status
            let recording_on = get_recording_override().unwrap_or(false);
            lines.push_str(&format!(
                "   Recording: {}\n",
                if recording_on { "on ✓" } else { "off" }
            ));

            lines.push('\n');
            lines.push_str("   /browser connect ws://host:port — connect to specific endpoint\n");
            lines.push_str("   /browser disconnect            — revert to default\n");
            lines.push_str("   /browser tabs                  — list open Chrome tabs\n");
            lines.push_str("   /browser recording on|off      — toggle session recording\n");

            self.push_output(lines, OutputRole::System);

        // ── tabs ──────────────────────────────────────────────────────────────
        } else if sub == "tabs" {
            // Resolve the active endpoint (override or default)
            let (tab_host, tab_port) = if let Some(ref ep) = cdp_override_status() {
                let parts: Vec<&str> = ep.splitn(2, ':').collect();
                let h = parts.first().copied().unwrap_or("127.0.0.1").to_string();
                let p: u16 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(9222);
                (h, p)
            } else {
                ("127.0.0.1".to_string(), 9222u16)
            };

            // Probe before hitting the HTTP API so we give a specific error
            let port_up = self.rt_handle.block_on(probe_cdp_port(&tab_host, tab_port));
            if !port_up {
                let tip = if cdp_override_status().is_some() {
                    format!(
                        "Chrome is not reachable at {tab_host}:{tab_port}.\n\
                         It may have closed after you connected.\n\
                         Run /browser connect to reconnect, or /browser disconnect to revert."
                    )
                } else {
                    format!(
                        "Chrome is not running with remote debugging on port {tab_port}.\n\
                         Run /browser connect to auto-launch Chrome with CDP enabled."
                    )
                };
                self.push_output(tip, OutputRole::System);
            } else {
                let tabs = self.rt_handle.block_on(list_cdp_tabs());
                if tabs.is_empty() {
                    self.push_output(
                        "Chrome is reachable but has no open page tabs yet.\n\
                     Open a page in Chrome and try /browser tabs again.",
                        OutputRole::System,
                    );
                } else {
                    let mut text = format!("🌐 Open Chrome tabs ({}):\n\n", tabs.len());
                    for (i, tab) in tabs.iter().enumerate() {
                        if tab.tab_type == "page" {
                            text.push_str(&format!(
                                "  {:2}. {}\n      {}\n\n",
                                i + 1,
                                tab.title,
                                tab.url
                            ));
                        }
                    }
                    // Also list non-page targets (service workers, extensions)
                    let other: Vec<_> = tabs.iter().filter(|t| t.tab_type != "page").collect();
                    if !other.is_empty() {
                        text.push_str(&format!(
                            "  ({} background target(s): service workers, extensions)\n",
                            other.len()
                        ));
                    }
                    self.push_output(text, OutputRole::System);
                }
            } // end port_up branch

        // ── recording on|off ──────────────────────────────────────────────────
        } else if sub.starts_with("recording") {
            let toggle = args
                .trim()
                .to_lowercase()
                .strip_prefix("recording")
                .map(str::trim)
                .map(str::to_string)
                .unwrap_or_default();
            match toggle.as_str() {
                "on" | "true" | "1" | "yes" => {
                    set_recording_override(true);
                    self.push_output(
                        "🔴 Browser session recording: ON\n\
                         Sessions will be recorded starting from the next browser_navigate.\n\
                         Recordings saved to ~/.edgecrab/browser_recordings/",
                        OutputRole::System,
                    );
                }
                "off" | "false" | "0" | "no" => {
                    set_recording_override(false);
                    self.push_output(
                        "⚫ Browser session recording: OFF\n\
                         New sessions will not be recorded. (In-progress recordings finish normally.)",
                        OutputRole::System,
                    );
                }
                _ => {
                    let current = get_recording_override()
                        .map_or("(config default)", |v| if v { "on" } else { "off" });
                    self.push_output(
                        format!(
                            "Browser recording is currently: {current}\n\n\
                             /browser recording on   — enable session recording\n\
                             /browser recording off  — disable session recording"
                        ),
                        OutputRole::System,
                    );
                }
            }

        // ── help / unknown ────────────────────────────────────────────────────
        } else {
            self.push_output(
                "Usage: /browser <subcommand>\n\n\
                 connect [url]       Connect browser tools to your live Chrome session\n\
                 disconnect          Revert to default headless browser backend\n\
                 status              Show current browser mode, endpoint, and Chrome version\n\
                 tabs                List all open Chrome tabs with titles and URLs\n\
                 recording on|off    Toggle session recording at runtime\n\n\
                 Examples:\n\
                 /browser connect\n\
                 /browser connect ws://192.168.1.10:9222\n\
                 /browser tabs\n\
                 /browser recording on",
                OutputRole::System,
            );
        }
    }

    // ─── Rendering ──────────────────────────────────────────────────

    /// Render the full application frame.
    pub fn render(&mut self, frame: &mut Frame) {
        // Cache terminal width for event handlers that build tool display spans
        self.last_terminal_width = frame.area().width;

        let textarea_height = self.input_area_height_for_area(frame.area());
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(1),                                           // output area
                Constraint::Length(1),                                        // separator
                Constraint::Length(if self.show_status_bar { 1 } else { 0 }), // status bar
                Constraint::Length(textarea_height), // input area (dynamic height)
            ])
            .split(frame.area());

        self.render_output(frame, chunks[0]);
        // Thin horizontal separator between output and status
        let sep = Paragraph::new(Line::from("─".repeat(chunks[1].width as usize)))
            .style(Style::default().fg(Color::Rgb(60, 60, 70)));
        frame.render_widget(sep, chunks[1]);
        if self.show_status_bar {
            self.render_status_bar(frame, chunks[2]);
        }
        self.render_input(frame, chunks[3]);

        // Model selector overlay (full screen)
        if self.model_selector.active {
            self.render_model_selector(frame, frame.area());
        }

        // Vision-model selector overlay (full screen)
        if self.vision_model_selector.active {
            self.render_vision_model_selector(frame, frame.area());
        }

        // Image-model selector overlay (full screen)
        if self.image_model_selector.active {
            self.render_image_model_selector(frame, frame.area());
        }

        if self.moa_reference_selector.active {
            self.render_moa_reference_selector(frame, frame.area());
        }

        // MCP selector overlay (full screen, same family as /model)
        if self.mcp_selector.active {
            self.render_mcp_selector(frame, frame.area());
        }

        if self.remote_mcp_browser.selector.active {
            self.render_remote_mcp_selector(frame, frame.area());
        }

        if self.profile_selector.active {
            self.render_profile_selector(frame, frame.area());
        }

        // Skill selector overlay (full screen, takes precedence over model selector)
        if self.skill_selector.active {
            self.render_skill_selector(frame, frame.area());
        }

        if self.tool_manager.active {
            self.render_tool_manager(frame, frame.area());
        }

        if self.plugin_toggle.active {
            self.render_plugin_toggle(frame, frame.area());
        }

        if self.remote_plugin_browser.selector.active {
            self.render_remote_plugin_selector(frame, frame.area());
        }

        if self.remote_skill_browser.selector.active {
            self.render_remote_skill_selector(frame, frame.area());
        }

        if self.config_selector.active {
            self.render_config_selector(frame, frame.area());
        }

        if self.gateway_browser.active {
            self.render_gateway_browser(frame, frame.area());
        }

        if self.diagnose_panel.active {
            self.render_diagnose_panel(frame, frame.area());
        }

        if self.log_browser.active {
            self.render_log_browser(frame, frame.area());
        }

        // Session browser overlay (full screen, same precedence as skill browser)
        if self.session_browser.active {
            self.render_session_browser(frame, frame.area());
        }

        if self.log_inspector.active() {
            self.render_log_inspector(frame, frame.area());
        }

        if self.session_inspector.active() {
            self.render_session_inspector(frame, frame.area());
        }

        // Skin browser overlay (full screen, same precedence as session browser)
        if self.skin_browser.active {
            self.render_skin_browser(frame, frame.area());
        }

        // Verbose / tool-progress picker overlay (compact centered popup)
        if self.verbose_selector_active {
            self.render_verbose_selector(frame, frame.area());
        }

        // Reasoning picker overlay (compact centered popup)
        if self.reasoning_selector_active {
            self.render_reasoning_selector(frame, frame.area());
        }

        // Personality picker overlay (compact centered popup)
        if self.personality_selector_active {
            self.render_personality_selector(frame, frame.area());
        }

        // Stream picker overlay (compact centered popup)
        if self.stream_selector_active {
            self.render_stream_selector(frame, frame.area());
        }

        // Status bar picker overlay (compact centered popup)
        if self.statusbar_selector_active {
            self.render_statusbar_selector(frame, frame.area());
        }

        // Steering overlay (compact floating panel — lower screen half)
        if self.steering_overlay_active {
            self.render_steering_overlay(frame, frame.area());
        }

        if self.document_overlay.is_some() {
            self.render_document_overlay(frame, frame.area());
        }

        // Approval overlay (full screen, highest precedence)
        if matches!(self.display_state, DisplayState::WaitingForApproval { .. }) {
            self.render_approval_overlay(frame, frame.area());
        }

        // Secret capture overlay (full screen, highest precedence — masks the secret)
        if matches!(self.display_state, DisplayState::SecretCapture { .. }) {
            self.render_secret_capture_overlay(frame, frame.area());
        }
        if matches!(self.display_state, DisplayState::ValueCapture { .. }) {
            self.render_value_capture_overlay(frame, frame.area());
        }
    }

    /// Render the scrollable output area with markdown formatting and a scrollbar.
    fn render_output(&mut self, frame: &mut Frame, area: Rect) {
        if !self.rich_transcript {
            self.render_output_compact(frame, area);
            return;
        }

        // ── Pass 1: ensure every OutputLine has a cached render ──────
        for ol in &mut self.output {
            if ol.rendered.is_none() {
                let rendered = if let Some(ref spans) = ol.prebuilt_spans {
                    // Pre-built spans (tool-done lines with emoji) — use directly.
                    // Ratatui measures each Span's display width via unicode-width,
                    // so emoji and wide characters align correctly.
                    vec![Line::from(spans.clone())]
                } else if ol.role == OutputRole::Assistant {
                    markdown_render::render_markdown(&ol.text)
                } else {
                    let style = match ol.role {
                        OutputRole::Assistant => unreachable!(),
                        OutputRole::Tool => Style::default()
                            .fg(Color::Rgb(255, 191, 0))
                            .add_modifier(Modifier::DIM),
                        OutputRole::System => Style::default()
                            .fg(Color::Rgb(140, 140, 150))
                            .add_modifier(Modifier::ITALIC),
                        OutputRole::Reasoning => Style::default()
                            .fg(Color::Rgb(170, 170, 190))
                            .add_modifier(Modifier::ITALIC | Modifier::DIM),
                        OutputRole::Error => Style::default().fg(Color::Rgb(239, 83, 80)),
                        OutputRole::User => Style::default().fg(Color::Rgb(255, 248, 220)),
                    };
                    ol.text
                        .lines()
                        .map(|l| Line::from(Span::styled(l.to_string(), style)))
                        .collect()
                };
                ol.rendered = Some(rendered);
            }
        }

        // ── Pass 2: build visual lines with role bars + turn separators ─
        // Each message gets a 2-char left accent: coloured "▎ " for most roles,
        // "· " (dimmed dot) for system messages. User messages get a thin
        // horizontal rule injected before them (except the very first).
        let sep_style = Style::default()
            .fg(Color::Rgb(45, 45, 58))
            .add_modifier(Modifier::DIM);
        // Dynamic separator width: fill the content column minus bar + scrollbar
        let sep_width = (area.width.saturating_sub(4) as usize).max(10);

        let mut lines: Vec<Line<'static>> = Vec::new();
        for (idx, ol) in self.output.iter().enumerate() {
            // Turn separator: thin rule before each user message that follows
            // at least one other message (marks start of a new conversation turn).
            if ol.role == OutputRole::User && idx > 0 {
                // Blank line + subtle separator rule
                lines.push(Line::from(""));
                lines.push(Line::from(vec![
                    Span::styled("  ", Style::default()),
                    Span::styled("─".repeat(sep_width), sep_style),
                ]));
                lines.push(Line::from(""));
            }

            // Role bar: the 2-char left accent column
            let (bar, bar_style): (&'static str, Style) = match ol.role {
                OutputRole::User => ("▎ ", Style::default().fg(Color::Rgb(255, 248, 220))),
                OutputRole::Assistant => ("▎ ", Style::default().fg(Color::Rgb(77, 208, 225))),
                OutputRole::Tool => ("▎ ", Style::default().fg(Color::Rgb(255, 191, 0))),
                OutputRole::Error => ("▎ ", Style::default().fg(Color::Rgb(239, 83, 80))),
                OutputRole::System => (". ", Style::default().fg(Color::Rgb(60, 60, 72))),
                OutputRole::Reasoning => ("~ ", Style::default().fg(Color::Rgb(95, 95, 115))),
            };

            // Prepend bar to every rendered sub-line
            for rendered_line in ol.rendered.as_ref().unwrap() {
                let mut spans: Vec<Span<'static>> = vec![Span::styled(bar, bar_style)];
                spans.extend(rendered_line.spans.clone());
                lines.push(Line::from(spans));
            }
        }

        // ── Ghost waiting line (FP45) ─────────────────────────────────
        // During AwaitingFirstToken and Thinking (when no reasoning output is
        // yet visible), inject a dim pulsing line at the bottom of the content
        // area. This puts the waiting indicator AT THE USER'S FOCAL POINT
        // (bottom of the conversation) rather than only in the peripheral
        // status bar. The ghost line disappears naturally once real tokens arrive.
        match &self.display_state {
            DisplayState::AwaitingFirstToken { frame, started } => {
                let spinner = SPINNER_FRAMES[*frame % SPINNER_FRAMES.len()];
                let elapsed = started.elapsed().as_secs();
                let ghost_text: String = if elapsed > 10 {
                    format!("  {spinner}  awaiting response\u{2026}  {elapsed}s  (^C to stop)")
                } else if elapsed > 3 {
                    format!("  {spinner}  awaiting response\u{2026}  {elapsed}s")
                } else {
                    format!("  {spinner}  awaiting response\u{2026}")
                };
                lines.push(Line::from(vec![
                    Span::styled(
                        "\u{258e} ",
                        Style::default()
                            .fg(P::GUTTER_BAR)
                            .add_modifier(Modifier::DIM), // decorative glyph — DIM OK
                    ),
                    Span::styled(
                        ghost_text,
                        Style::default()
                            // WCAG AA: P::TERTIARY_COOL Rgb(125,138,162) CR=6.0:1.
                            // DIM removed — this is semantic text the user must read.
                            .fg(P::TERTIARY_COOL)
                            .add_modifier(Modifier::ITALIC),
                    ),
                ]));
            }
            DisplayState::Thinking { frame, started }
                // Only show when reasoning is not already streaming in the output
                // area (show_reasoning=true with tokens arriving). When
                // reasoning_line is Some, the user already sees live reasoning
                // text — adding a ghost line would duplicate the signal.
                if self.reasoning_line.is_none() => {
                    let spinner = SPINNER_FRAMES[*frame % SPINNER_FRAMES.len()];
                    let elapsed = started.elapsed().as_secs();
                    let ghost_text: String = if elapsed > 3 {
                        format!("  {spinner}  thinking\u{2026}  {elapsed}s")
                    } else {
                        format!("  {spinner}  thinking\u{2026}")
                    };
                    lines.push(Line::from(vec![
                        Span::styled(
                            "\u{258e} ",
                            Style::default()
                                .fg(P::GUTTER_BAR)
                                .add_modifier(Modifier::DIM), // decorative glyph — DIM OK
                        ),
                        Span::styled(
                            ghost_text,
                            Style::default()
                                // WCAG AA: P::SECONDARY_WARM CR=9.2:1. DIM removed.
                                .fg(P::SECONDARY_WARM)
                                .add_modifier(Modifier::ITALIC),
                        ),
                    ]));
                }
            _ => {}
        }

        // ── Scroll math ───────────────────────────────────────────────
        //
        // Scrollbar is on the LEFT (1 col).  Content starts at x+1.
        // WHY left: the content's natural reading edge is the right margin;
        // placing the scroll indicator on the left avoids it competing with
        // text flow and emoji that may appear near the right edge.
        //   area.x  ← scrollbar (1 col)
        //   area.x+1 .. area.right()  ← text content (width − 1 cols)
        //
        // content_width: used for word-wrap row count estimation.
        // Subtract 4 = 1 (scrollbar) + 1 (gap) + 2 (role bar "▎ ").
        let content_width = area.width.saturating_sub(4) as usize;

        let visual_rows: u16 = if content_width == 0 {
            lines.len() as u16
        } else {
            lines
                .iter()
                .map(|l| {
                    let w = l.width();
                    if w == 0 {
                        1u16
                    } else {
                        w.div_ceil(content_width) as u16
                    }
                })
                .sum()
        };

        let visible_height = area.height;
        let max_scroll = visual_rows.saturating_sub(visible_height);
        if self.scroll_offset > max_scroll {
            self.scroll_offset = max_scroll;
        }
        let scroll = self.scroll_offset;

        self.output_visual_rows = visual_rows;
        self.output_area_height = visible_height;
        self.at_bottom = scroll == 0;

        let top_row = visual_rows.saturating_sub(visible_height + scroll);

        // ── Render: scrollbar LEFT, 1-col gap, then content ──────────
        let scrollbar_area = Rect {
            x: area.x,
            y: area.y,
            width: 1,
            height: area.height,
        };
        // Content column: skip 1 col (scrollbar) + 1 col (breathing gap).
        let content_area = Rect {
            x: area.x + 2,
            y: area.y,
            width: area.width.saturating_sub(2),
            height: area.height,
        };

        let paragraph = Paragraph::new(Text::from(lines))
            .wrap(Wrap { trim: false })
            .scroll((top_row, 0));
        frame.render_widget(paragraph, content_area);

        if visual_rows > visible_height {
            let scrollbar_pos = max_scroll.saturating_sub(scroll) as usize;
            let mut scrollbar_state =
                ScrollbarState::new(max_scroll as usize).position(scrollbar_pos);
            frame.render_stateful_widget(
                Scrollbar::new(ScrollbarOrientation::VerticalLeft)
                    .begin_symbol(None)
                    .end_symbol(None)
                    .track_symbol(Some("│"))
                    .thumb_symbol("█"),
                scrollbar_area,
                &mut scrollbar_state,
            );
        }

        // "Scrolled ↑" hint — anchored to right edge of the content area
        // (not the scrollbar edge) so it stays readable.
        if scroll > 0 {
            let hint = format!(
                " ↑{}  ^G=end  ↕scroll  {} ",
                scroll,
                self.paging_key_hint_label()
            );
            let hint_len = hint
                .len()
                .min(content_area.width.saturating_sub(1) as usize);
            let hint_x = content_area.x + content_area.width.saturating_sub(hint_len as u16);
            let hint_area = Rect::new(hint_x, area.y, hint_len as u16, 1);
            frame.render_widget(
                Paragraph::new(Span::styled(
                    hint,
                    Style::default()
                        .fg(Color::Rgb(255, 210, 50))
                        .bg(Color::Rgb(30, 30, 38))
                        .add_modifier(Modifier::BOLD),
                )),
                hint_area,
            );
        }
    }

    fn render_output_compact(&mut self, frame: &mut Frame, area: Rect) {
        let glyphs = self.terminal_glyph_profile;
        let flatten_spans = |spans: &[Span<'static>]| -> String {
            spans
                .iter()
                .map(|span| span.content.as_ref())
                .collect::<String>()
        };
        let line_style = |role: OutputRole| match role {
            OutputRole::Assistant => Style::default().fg(Color::Rgb(220, 228, 235)),
            OutputRole::Tool => Style::default().fg(Color::Rgb(235, 200, 120)),
            OutputRole::System => Style::default()
                .fg(Color::Rgb(155, 165, 175))
                .add_modifier(Modifier::DIM),
            OutputRole::Reasoning => Style::default()
                .fg(Color::Rgb(145, 150, 170))
                .add_modifier(Modifier::DIM),
            OutputRole::Error => Style::default().fg(Color::Rgb(239, 83, 80)),
            OutputRole::User => Style::default().fg(Color::Rgb(255, 248, 220)),
        };
        let prefix_for = |role: OutputRole, ascii: bool| match (role, ascii) {
            (OutputRole::Tool, true) => "[tool] ",
            (OutputRole::Tool, false) => "› ",
            (OutputRole::System, true) => "[note] ",
            (OutputRole::System, false) => "· ",
            (OutputRole::Reasoning, true) => "[think] ",
            (OutputRole::Reasoning, false) => "~ ",
            (OutputRole::Error, true) => "[error] ",
            (OutputRole::Error, false) => "! ",
            _ => "",
        };

        for ol in &mut self.output {
            if ol.plain_rendered.is_none() {
                let style = line_style(ol.role);
                let prefix = prefix_for(ol.role, matches!(glyphs, TerminalGlyphProfile::Ascii));
                let prefix_width = prefix.width();
                let text = ol
                    .prebuilt_spans
                    .as_ref()
                    .map(|spans| flatten_spans(spans))
                    .unwrap_or_else(|| ol.text.clone());
                let source_lines = if text.is_empty() {
                    vec![String::new()]
                } else {
                    text.lines().map(str::to_string).collect::<Vec<_>>()
                };
                let mut rendered_lines = Vec::with_capacity(source_lines.len());
                for (idx, content) in source_lines.into_iter().enumerate() {
                    let leader = if idx == 0 {
                        prefix.to_string()
                    } else if prefix_width == 0 {
                        String::new()
                    } else {
                        " ".repeat(prefix_width)
                    };
                    rendered_lines.push(Line::from(vec![
                        Span::styled(leader, style),
                        Span::styled(content, style),
                    ]));
                }
                ol.plain_rendered = Some(rendered_lines);
            }
        }

        let mut lines: Vec<Line<'static>> = Vec::new();
        for ol in &self.output {
            if let Some(rendered) = &ol.plain_rendered {
                lines.extend(rendered.clone());
            }
        }

        // ── Ghost waiting line (FP45) compact variant ─────────────────
        match &self.display_state {
            DisplayState::AwaitingFirstToken { frame, started } => {
                let spinner = SPINNER_FRAMES[*frame % SPINNER_FRAMES.len()];
                let elapsed = started.elapsed().as_secs();
                let ghost: String = if elapsed > 3 {
                    format!("  {spinner}  awaiting\u{2026}  {elapsed}s")
                } else {
                    format!("  {spinner}  awaiting\u{2026}")
                };
                lines.push(Line::from(Span::styled(
                    ghost,
                    Style::default()
                        // WCAG AA: P::TERTIARY_COOL CR=6.0:1. DIM removed.
                        .fg(P::TERTIARY_COOL)
                        .add_modifier(Modifier::ITALIC),
                )));
            }
            DisplayState::Thinking { frame, started } if self.reasoning_line.is_none() => {
                let spinner = SPINNER_FRAMES[*frame % SPINNER_FRAMES.len()];
                let elapsed = started.elapsed().as_secs();
                let ghost: String = if elapsed > 3 {
                    format!("  {spinner}  thinking\u{2026}  {elapsed}s")
                } else {
                    format!("  {spinner}  thinking\u{2026}")
                };
                lines.push(Line::from(Span::styled(
                    ghost,
                    Style::default()
                        // WCAG AA: P::SECONDARY_WARM CR=9.2:1 (thinking = higher priority signal).
                        // DIM removed.
                        .fg(P::SECONDARY_WARM)
                        .add_modifier(Modifier::ITALIC),
                )));
            }
            _ => {}
        }

        let content_width = area.width.max(1) as usize;
        let visual_rows: u16 = lines
            .iter()
            .map(|line| {
                let width = line.width();
                if width == 0 {
                    1
                } else {
                    width.div_ceil(content_width) as u16
                }
            })
            .sum();
        let visible_height = area.height;
        let max_scroll = visual_rows.saturating_sub(visible_height);
        if self.scroll_offset > max_scroll {
            self.scroll_offset = max_scroll;
        }
        let scroll = self.scroll_offset;
        self.output_visual_rows = visual_rows;
        self.output_area_height = visible_height;
        self.at_bottom = scroll == 0;

        let top_row = visual_rows.saturating_sub(visible_height + scroll);
        let paragraph = Paragraph::new(Text::from(lines))
            .wrap(Wrap { trim: false })
            .scroll((top_row, 0));
        frame.render_widget(paragraph, area);

        if self.show_output_scrollbar && visual_rows > visible_height && area.width > 1 {
            let scrollbar_area = Rect {
                x: area.right().saturating_sub(1),
                y: area.y,
                width: 1,
                height: area.height,
            };
            let scrollbar_pos = max_scroll.saturating_sub(scroll) as usize;
            let mut scrollbar_state =
                ScrollbarState::new(max_scroll as usize).position(scrollbar_pos);
            frame.render_stateful_widget(
                Scrollbar::new(ScrollbarOrientation::VerticalRight)
                    .begin_symbol(None)
                    .end_symbol(None)
                    .track_symbol(Some("|"))
                    .thumb_symbol("#"),
                scrollbar_area,
                &mut scrollbar_state,
            );
        }

        if scroll > 0 && area.width > 12 {
            let hint =
                self.paging_scroll_hint(scroll, matches!(glyphs, TerminalGlyphProfile::Ascii));
            let hint_width = hint.width().min(area.width as usize) as u16;
            let hint_area = Rect::new(
                area.right().saturating_sub(hint_width),
                area.y,
                hint_width,
                1,
            );
            frame.render_widget(
                Paragraph::new(Span::styled(
                    hint,
                    Style::default()
                        .fg(Color::Rgb(240, 210, 120))
                        .bg(Color::Rgb(30, 30, 38))
                        .add_modifier(Modifier::BOLD),
                )),
                hint_area,
            );
        }
    }

    /// Render the status bar with spinner and color-coded metrics.
    fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
        if self.compact_status_bar {
            self.render_compact_status_bar(frame, area);
            return;
        }

        let mut left_spans = Vec::new();

        // ── Brand badge ─────────────────────────────────────────────
        // A small copper "EC" badge anchors the left side of the status bar.
        left_spans.push(Span::styled(
            " EC ",
            Style::default()
                .fg(Color::Rgb(205, 127, 50))
                .add_modifier(Modifier::BOLD),
        ));
        left_spans.push(Span::styled(
            "│",
            Style::default().fg(Color::Rgb(50, 50, 65)),
        ));
        left_spans.push(Span::styled(
            format!(" v{} ", crate::banner::VERSION),
            Style::default().fg(Color::Rgb(120, 130, 150)),
        ));
        left_spans.push(Span::styled(
            "│",
            Style::default().fg(Color::Rgb(50, 50, 65)),
        ));

        // ── Spinner / state indicator ────────────────────────────────
        match &self.display_state {
            DisplayState::AwaitingFirstToken { frame: f, started } => {
                let elapsed_secs = started.elapsed().as_secs();
                let msg = format_waiting_first_token_status(
                    &self.theme,
                    *f,
                    self.thinking_verb_idx,
                    self.kaomoji_frame_idx,
                    elapsed_secs,
                );
                // FP46: urgency color ramp — amber (normal) → orange (slow) → red (stall)
                let color = wait_urgency_color(elapsed_secs);
                left_spans.push(Span::styled(format!(" {msg} "), Style::default().fg(color)));
            }
            DisplayState::Thinking { frame: f, started } => {
                let elapsed_secs = started.elapsed().as_secs();
                let msg = format_thinking_status(
                    &self.theme,
                    *f,
                    self.thinking_verb_idx,
                    self.kaomoji_frame_idx,
                    elapsed_secs,
                );
                // FP46: same urgency ramp for extended reasoning waits
                let color = wait_urgency_color(elapsed_secs);
                left_spans.push(Span::styled(format!(" {msg} "), Style::default().fg(color)));
            }
            DisplayState::Streaming {
                token_count,
                chars_written,
                current_section,
                started,
            } => {
                let elapsed = started.elapsed().as_secs_f64();
                // Only show rate once enough tokens and time have elapsed to
                // produce a meaningful estimate — avoids "1t/s" flicker on start.
                let rate_str = if elapsed > 1.0 && *token_count > 5 {
                    let rate = *token_count as f64 / elapsed;
                    format!("  {rate:.0}t/s")
                } else {
                    String::new()
                };
                // Word estimate: ~4.5 chars per word, bucketed to nearest 10 for stability.
                let words = words_estimate(*chars_written);
                let word_str = if words > 0 {
                    format!(" ~{words}w ")
                } else {
                    String::new()
                };
                // Show elapsed time after 5s of streaming so user knows how long they've waited.
                let elapsed_str = format_elapsed_hint(started.elapsed(), 5);
                // Show current section heading if we detected one.
                let section_str = current_section
                    .as_deref()
                    .map(|s| format!(" │ {s}"))
                    .unwrap_or_default();
                left_spans.push(Span::styled(
                    format!(" ▶{word_str}{section_str}{rate_str}{elapsed_str} "),
                    Style::default().fg(Color::Rgb(100, 230, 100)),
                ));
            }
            DisplayState::ToolExec {
                name,
                args_json,
                detail,
                frame: f,
                started,
                ..
            } => {
                let spinner = SPINNER_FRAMES[*f % SPINNER_FRAMES.len()];
                let summary = summarize_active_tools(&self.active_tools);
                let elapsed_secs = summary
                    .as_ref()
                    .map(|active| active.elapsed_secs)
                    .unwrap_or_else(|| started.elapsed().as_secs());
                // Show elapsed time after 3 s (mirrors Thinking state behaviour).
                // Show the stop hint after 10 s for long-running tools.
                let time_part = if elapsed_secs >= 3 {
                    format!(" {elapsed_secs}s")
                } else {
                    String::new()
                };
                let stop_hint = if elapsed_secs >= 10 { "  ^C=stop" } else { "" };
                let content = if let Some(active) = summary {
                    format!(
                        " {spinner} {} {} {}{time_part}{stop_hint} ",
                        active.verb, active.icon, active.detail
                    )
                } else {
                    let icon = tool_icon(name);
                    let verb = tool_action_verb(name);
                    let preview = detail
                        .as_deref()
                        .filter(|detail| !detail.trim().is_empty())
                        .map(|detail| edgecrab_core::safe_truncate(detail.trim(), 60).to_string())
                        .unwrap_or_else(|| {
                            // Use width-adaptive preview — status bar has more room on wide terminals.
                            let widths = DisplayWidths::from_terminal_width(
                                self.last_terminal_width as usize,
                            );
                            tool_status_preview_width(name, args_json, widths.status_preview)
                        });
                    format!(" {spinner} {verb} {icon} {preview}{time_part}{stop_hint} ")
                };
                // FP48: Use semantic category color so the status bar matches the output area.
                // FP50: Escalate color for slow (>=5s) and stalled (>=15s) tool calls.
                let category_name = latest_active_tool_entry(&self.active_tools)
                    .map(|(_, s)| s.name.as_str())
                    .unwrap_or(name.as_str());
                let base_color = tool_category(category_name).name_color();
                let bar_color = if elapsed_secs >= 15 {
                    Color::Rgb(255, 140, 50) // orange: stalled / very slow
                } else if elapsed_secs >= 5 {
                    Color::Rgb(255, 200, 80) // amber: slow
                } else {
                    base_color
                };
                left_spans.push(Span::styled(content, Style::default().fg(bar_color)));
            }
            DisplayState::BgOp {
                label,
                frame: f,
                started,
            } => {
                let spinner = SPINNER_FRAMES[*f % SPINNER_FRAMES.len()];
                let elapsed = started.elapsed().as_secs();
                let msg = if elapsed > 3 {
                    format!(" {spinner} {label} {elapsed}s ")
                } else {
                    format!(" {spinner} {label} ")
                };
                left_spans.push(Span::styled(
                    msg,
                    Style::default().fg(Color::Rgb(180, 180, 255)),
                ));
            }
            DisplayState::Idle => {
                if let Some(outcome) = self.last_run_outcome.as_ref() {
                    left_spans.push(Span::styled(
                        format!(
                            " {} {} ",
                            outcome.state.emoji(),
                            outcome.state.compact_label()
                        ),
                        run_outcome_badge_style(outcome),
                    ));
                } else {
                    left_spans.push(Span::raw(" "));
                }
            }
            DisplayState::WaitingForClarify => {
                // Agent is paused waiting for a user reply to a clarifying question.
                // Show a distinct amber label so the user knows input is expected.
                left_spans.push(Span::styled(
                    " ❓ Waiting for reply ",
                    Style::default()
                        .fg(Color::Rgb(255, 220, 80))
                        .add_modifier(Modifier::BOLD),
                ));
            }
            DisplayState::WaitingForApproval { command, .. } => {
                // Agent is waiting for a risk-graduated approval from the user.
                let short = if command.len() > 30 {
                    format!("{}…", edgecrab_core::safe_truncate(command, 27))
                } else {
                    command.clone()
                };
                left_spans.push(Span::styled(
                    format!(" ⚠  Approve: {short} "),
                    Style::default()
                        .fg(Color::Rgb(255, 140, 0))
                        .add_modifier(Modifier::BOLD),
                ));
            }
            DisplayState::SecretCapture {
                var_name, is_sudo, ..
            } => {
                // Agent is waiting for a secret value from the user.
                let label = if *is_sudo {
                    format!(" 🔒 sudo: {var_name} ")
                } else {
                    format!(" 🔑 secret: {var_name} ")
                };
                left_spans.push(Span::styled(
                    label,
                    Style::default()
                        .fg(Color::Rgb(255, 80, 80))
                        .add_modifier(Modifier::BOLD),
                ));
            }
            DisplayState::ValueCapture { title, .. } => {
                left_spans.push(Span::styled(
                    format!(" ⛵ Editing: {title} "),
                    Style::default()
                        .fg(Color::Rgb(120, 220, 200))
                        .add_modifier(Modifier::BOLD),
                ));
            }
        }

        left_spans.push(Span::styled(
            "│",
            Style::default().fg(Color::Rgb(50, 50, 65)),
        ));

        if let Some(overlay) = self.document_overlay.as_ref() {
            left_spans.push(Span::styled(
                format!(" {} {} ", overlay.icon, unicode_trunc(&overlay.title, 28)),
                Style::default()
                    .fg(overlay.accent)
                    .add_modifier(Modifier::BOLD),
            ));
            left_spans.push(Span::styled(
                "│",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
        }

        // Model name
        left_spans.push(Span::styled(
            format!(" {} ", self.model_name),
            self.theme.status_bar_model,
        ));

        // Token count with color threshold.
        // When context window is known, show a watermark: `12.4k / 200k (7%)`.
        // Color: green → yellow → red at 50% / 80% of context window.
        let ctx_pct = self
            .context_window
            .and_then(|cw| context_usage_ratio(self.total_tokens, Some(cw)));
        let token_style = if ctx_pct.is_some_and(|p| p > 0.80) || self.total_tokens > 100_000 {
            Style::default().fg(Color::Red)
        } else if ctx_pct.is_some_and(|p| p > 0.50) || self.total_tokens > 50_000 {
            Style::default().fg(Color::Yellow)
        } else {
            self.theme.status_bar_tokens
        };
        let token_display = if let (Some(cw), Some(pct)) = (self.context_window, ctx_pct) {
            format!(
                " {}/{} ({:.0}%)",
                format_tokens(self.total_tokens),
                format_tokens(cw),
                pct * 100.0
            )
        } else {
            format!(" {}", format_tokens(self.total_tokens))
        };
        left_spans.push(Span::styled(token_display, token_style));

        // Cost with color threshold
        let cost_style = if self.session_cost >= 1.0 {
            Style::default().fg(Color::Red)
        } else if self.session_cost >= 0.10 {
            Style::default().fg(Color::Yellow)
        } else {
            self.theme.status_bar_cost
        };
        left_spans.push(Span::styled(
            format!(" ${:.4}", self.session_cost),
            cost_style,
        ));

        // ── Context pressure gauge ───────────────────────────────────
        if self.context_window.is_some() {
            left_spans.push(Span::styled(
                " │ ",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
            let gauge_spans =
                build_context_gauge(self.total_tokens, self.context_window.unwrap_or(0));
            left_spans.extend(gauge_spans);
        }

        if let Some(presence) = self.voice_presence_state() {
            left_spans.push(Span::styled(
                " │ ",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
            let style = match presence {
                VoicePresenceState::Recording { .. } => Style::default()
                    .fg(Color::Rgb(30, 20, 20))
                    .bg(Color::Rgb(240, 110, 90))
                    .add_modifier(Modifier::BOLD),
                VoicePresenceState::Speaking => Style::default()
                    .fg(Color::Rgb(10, 24, 38))
                    .bg(Color::Rgb(120, 210, 255))
                    .add_modifier(Modifier::BOLD),
                VoicePresenceState::Listening => Style::default()
                    .fg(Color::Rgb(18, 32, 26))
                    .bg(Color::Rgb(120, 225, 165))
                    .add_modifier(Modifier::BOLD),
            };
            left_spans.push(Span::styled(
                format_voice_presence_badge(presence, self.voice_presence_frame_idx),
                style,
            ));
        }
        if !self.active_subagents.is_empty() {
            left_spans.push(Span::styled(
                " │ ",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
            left_spans.push(Span::styled(
                format!(" DG {} ", self.active_subagents.len()),
                Style::default()
                    .fg(Color::Rgb(10, 24, 38))
                    .bg(Color::Rgb(95, 170, 255))
                    .add_modifier(Modifier::BOLD),
            ));
            if let Some(summary) = format_subagent_status_summary(&self.active_subagents) {
                left_spans.push(Span::styled(
                    format!(" {summary} "),
                    Style::default()
                        .fg(Color::Rgb(165, 205, 245))
                        .add_modifier(Modifier::DIM),
                ));
            }
        }
        if !self.background_tasks_active.is_empty() {
            left_spans.push(Span::styled(
                " │ ",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
            left_spans.push(Span::styled(
                format!(" BG {} ", self.background_tasks_active.len()),
                Style::default()
                    .fg(Color::Rgb(20, 20, 28))
                    .bg(Color::Rgb(110, 180, 255))
                    .add_modifier(Modifier::BOLD),
            ));
            if let Some(summary) = format_background_status_summary(&self.background_tasks_active) {
                left_spans.push(Span::styled(
                    format!(" {summary} "),
                    Style::default()
                        .fg(Color::Rgb(180, 220, 255))
                        .add_modifier(Modifier::DIM),
                ));
            }
        }

        // ── Steering indicator ────────────────────────────────────────────
        // Show pending count in amber, or a brief "applied" flash in green
        // (fades after 3 seconds).
        if self.pending_steer_count > 0 {
            left_spans.push(Span::styled(
                " │ ",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
            left_spans.push(Span::styled(
                format!(" ⛵ {} pending ", self.pending_steer_count),
                Style::default()
                    .fg(Color::Rgb(20, 20, 28))
                    .bg(Color::Rgb(255, 190, 50))
                    .add_modifier(Modifier::BOLD),
            ));
        } else if self
            .steer_applied_at
            .is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(4))
        {
            left_spans.push(Span::styled(
                " │ ",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
            left_spans.push(Span::styled(
                " ⛵ applied ",
                Style::default()
                    .fg(Color::Rgb(18, 32, 26))
                    .bg(Color::Rgb(100, 215, 140))
                    .add_modifier(Modifier::BOLD),
            ));
        }

        // Right side: keyboard hints + turn counter
        let mut right_spans = Vec::new();
        if self.turn_count > 0 {
            right_spans.push(Span::styled(
                format!(" turn {} ", self.turn_count),
                Style::default().fg(Color::Rgb(80, 90, 110)),
            ));
            right_spans.push(Span::styled(
                "│",
                Style::default().fg(Color::Rgb(50, 50, 65)),
            ));
        }
        if self.scroll_offset > 0 {
            right_spans.push(Span::styled(
                format!(
                    " ↑SCROLLED  ^G=↓  ↕scroll  {} ",
                    self.paging_key_hint_label()
                ),
                Style::default()
                    .fg(Color::Rgb(255, 210, 50))
                    .add_modifier(Modifier::BOLD),
            ));
        } else {
            // ── Mode pill ─────────────────────────────────────────────────────
            // Always visible so the user knows the active mode and the key to
            // switch.  SCROLL (green) = mouse capture on, wheel scrolls output.
            //           SELECT (amber) = mouse capture off, native drag=copy.
            if self.mouse_capture_enabled {
                right_spans.push(Span::styled(
                    " SCROLL ",
                    Style::default()
                        .fg(Color::Rgb(20, 20, 28))
                        .bg(Color::Rgb(60, 185, 105))
                        .add_modifier(Modifier::BOLD),
                ));
            } else {
                right_spans.push(Span::styled(
                    " SELECT ",
                    Style::default()
                        .fg(Color::Rgb(20, 20, 28))
                        .bg(Color::Rgb(255, 200, 50))
                        .add_modifier(Modifier::BOLD),
                ));
            }
            // ── State-specific hints ──────────────────────────────────────────
            if !self.mouse_capture_enabled {
                right_spans.push(Span::styled(
                    " drag=copy  F6=scroll  Tab=complete  ^C=cancel ",
                    Style::default()
                        .fg(Color::Rgb(255, 210, 50))
                        .add_modifier(Modifier::BOLD),
                ));
            } else if self.clarify_pending_tx.is_some() {
                // Agent is awaiting a reply — emphasise the prompt so users know
                // their next Enter submits an answer, not a new conversation turn.
                right_spans.push(Span::styled(
                    " ↵=send reply  ^C=cancel  ↕scroll ",
                    Style::default()
                        .fg(Color::Rgb(255, 220, 80))
                        .add_modifier(Modifier::BOLD),
                ));
            } else if self.is_processing {
                right_spans.push(Span::styled(
                    " ^C=cancel  ^S=steer  ↕scroll ",
                    Style::default().fg(Color::Rgb(70, 75, 95)),
                ));
            } else if matches!(self.editor_mode, InputEditorMode::ComposeInsert) {
                right_spans.push(Span::styled(
                    " COMPOSE ",
                    Style::default()
                        .fg(Color::Rgb(20, 20, 28))
                        .bg(Color::Rgb(90, 200, 150))
                        .add_modifier(Modifier::BOLD),
                ));
                right_spans.push(Span::styled(
                    " INSERT  ↵=newline  ^S=send  Esc=normal ",
                    Style::default().fg(Color::Rgb(90, 210, 170)),
                ));
            } else if matches!(self.editor_mode, InputEditorMode::ComposeNormal) {
                right_spans.push(Span::styled(
                    " COMPOSE ",
                    Style::default()
                        .fg(Color::Rgb(20, 20, 28))
                        .bg(Color::Rgb(255, 191, 0))
                        .add_modifier(Modifier::BOLD),
                ));
                right_spans.push(Span::styled(
                    self.compose_normal_hint(),
                    Style::default().fg(Color::Rgb(255, 210, 80)),
                ));
            } else if !self.active_skills.is_empty() {
                // Show active skill names so the user knows which skills are loaded.
                // Typing /skill_name again deactivates; /skills opens the browser.
                let names = self.active_skills.join(" + ");
                right_spans.push(Span::styled(
                    format!(" 📚 {names}  F6=select  /skill off  ^C=cancel "),
                    Style::default()
                        .fg(Color::Rgb(100, 210, 120))
                        .add_modifier(Modifier::BOLD),
                ));
            } else {
                let voice_hint = if self.voice_push_to_talk_key.is_empty() {
                    String::new()
                } else {
                    format!(" {}=voice ", self.voice_push_to_talk_key.to_uppercase())
                };
                right_spans.push(Span::styled(
                    format!(
                        " F6=select  F1=help  {}  F2=model  F3=skills  F7=vision{} Tab=complete  ^C=cancel ",
                        self.inline_compose_hint(),
                        voice_hint
                    ),
                    Style::default().fg(Color::Rgb(70, 75, 95)),
                ));
            }
        }

        // Build two-sided status bar
        let right_line = Line::from(right_spans);
        let right_text = right_line
            .spans
            .iter()
            .map(|s| s.content.as_ref())
            .collect::<String>();
        // WHY .width() not .len(): multi-byte Unicode chars (↑↓↕ = 3 bytes, 📚 = 4 bytes)
        // inflate .len() past the terminal column count, causing right_area.right() to
        // exceed the ratatui buffer bounds → panic. UnicodeWidthStr gives display cols.
        let right_width = (right_text.width() as u16).min(area.width);

        let left_area = Rect {
            width: area.width.saturating_sub(right_width),
            ..area
        };
        let right_area = Rect {
            x: area.x + area.width.saturating_sub(right_width),
            width: right_width,
            ..area
        };

        let status = Paragraph::new(Line::from(left_spans))
            .style(Style::default().bg(Color::Rgb(30, 30, 38)));
        frame.render_widget(status, left_area);

        let right_status = Paragraph::new(right_line)
            .style(Style::default().bg(Color::Rgb(30, 30, 38)))
            .alignment(Alignment::Right);
        frame.render_widget(right_status, right_area);
    }

    fn render_compact_status_bar(&self, frame: &mut Frame, area: Rect) {
        let glyphs = self.terminal_glyph_profile;
        let divider = " | ";
        let state = match &self.display_state {
            DisplayState::Idle => self
                .last_run_outcome
                .as_ref()
                .map(|outcome| {
                    format!(
                        "{} {}",
                        outcome.state.emoji(),
                        outcome.state.compact_label()
                    )
                })
                .unwrap_or_else(|| "idle".to_string()),
            DisplayState::AwaitingFirstToken { frame, started } => format!(
                "{} wait {}s",
                compact_spinner_frame(*frame, glyphs),
                started.elapsed().as_secs()
            ),
            DisplayState::Thinking { frame, started } => format!(
                "{} think {}s",
                compact_spinner_frame(*frame, glyphs),
                started.elapsed().as_secs()
            ),
            DisplayState::Streaming {
                token_count,
                chars_written,
                started,
                ..
            } => {
                let secs = started.elapsed().as_secs().max(1);
                let words = words_estimate(*chars_written);
                if words > 0 {
                    format!("reply ~{}w {}t/s", words, token_count / secs)
                } else {
                    format!("reply {}tok {}t/s", token_count, token_count / secs)
                }
            }
            DisplayState::ToolExec { frame, started, .. } => format!(
                "{} tool {}s",
                compact_spinner_frame(*frame, glyphs),
                started.elapsed().as_secs()
            ),
            DisplayState::BgOp { frame, label, .. } => format!(
                "{} {}",
                compact_spinner_frame(*frame, glyphs),
                edgecrab_core::safe_truncate(label, 18)
            ),
            DisplayState::WaitingForClarify => "reply needed".into(),
            DisplayState::WaitingForApproval { .. } => "approval needed".into(),
            DisplayState::SecretCapture { .. } => "secret input".into(),
            DisplayState::ValueCapture { .. } => "editing".into(),
        };
        let token_display = if let (Some(cw), Some(pct)) = (
            self.context_window,
            context_usage_ratio(self.total_tokens, self.context_window),
        ) {
            format!(
                "{}/{} {:.0}%",
                format_tokens(self.total_tokens),
                format_tokens(cw),
                pct * 100.0
            )
        } else {
            format_tokens(self.total_tokens)
        };
        let mode = if self.mouse_capture_enabled {
            "scroll"
        } else {
            "select"
        };
        let transport = if self.remote_terminal_session {
            "ssh"
        } else {
            "local"
        };
        let profile = match self.terminal_ui_profile {
            TerminalUiProfile::Standard => "std",
            TerminalUiProfile::ReducedMotion => "calm",
            TerminalUiProfile::BasicCompat => "compat",
        };
        let right = format!(
            "t{}{}{}{}",
            self.turn_count,
            divider,
            mode,
            if self.remote_terminal_session {
                " ssh"
            } else {
                ""
            }
        );
        let left = format!(
            "{}{}{}{}{}{}${:.4}{}{}{}{}",
            state,
            divider,
            edgecrab_core::safe_truncate(&self.model_name, 18),
            divider,
            token_display,
            divider,
            self.session_cost,
            divider,
            transport,
            divider,
            profile,
        );
        let right_width = right.width().min(area.width as usize) as u16;
        let left_area = Rect {
            width: area.width.saturating_sub(right_width),
            ..area
        };
        let right_area = Rect {
            x: area.right().saturating_sub(right_width),
            width: right_width,
            ..area
        };
        let bg = Style::default().bg(Color::Rgb(30, 30, 38));
        frame.render_widget(
            Paragraph::new(Span::styled(
                edgecrab_core::safe_truncate(&left, left_area.width as usize),
                bg.fg(Color::Rgb(200, 205, 215)),
            ))
            .style(bg),
            left_area,
        );
        frame.render_widget(
            Paragraph::new(Span::styled(
                edgecrab_core::safe_truncate(&right, right_area.width as usize),
                bg.fg(Color::Rgb(120, 130, 150)),
            ))
            .style(bg)
            .alignment(Alignment::Right),
            right_area,
        );
    }

    fn render_model_like_selector(
        &self,
        frame: &mut Frame,
        area: Rect,
        selector: &FuzzySelector<ModelEntry>,
        detail_surface: DetailSurface,
        chrome: SelectorChrome<'_>,
    ) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &selector.query,
            BrowserChrome {
                title: chrome.title,
                placeholder: chrome.placeholder,
                icon: "◈",
                icon_color: Color::Cyan,
                border_color: Color::Cyan,
            },
        );

        let max_visible = body[0].height as usize;
        let filtered = &selector.filtered;
        let selected = selector.selected;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = filtered
            .iter()
            .skip(scroll_start)
            .take(max_visible)
            .enumerate()
            .map(|(vis_idx, &model_idx)| {
                let entry = &selector.items[model_idx];
                let (display, provider) = (&entry.display, &entry.provider);
                let is_selected = vis_idx + scroll_start == selected;
                let bg = if is_selected {
                    Color::Rgb(50, 50, 70)
                } else {
                    Color::Rgb(20, 20, 28)
                };
                let style = if is_selected {
                    Style::default()
                        .bg(bg)
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(Color::Rgb(200, 200, 200))
                };
                let provider_style = if is_selected {
                    Style::default().bg(bg).fg(Color::Rgb(120, 120, 150))
                } else {
                    Style::default().fg(Color::Rgb(80, 80, 100))
                };
                let mut spans = vec![
                    selector_marker(is_selected, Color::Cyan, Some(bg)),
                    Span::styled(format!("  {:<12}", provider), provider_style),
                    Span::styled(display.clone(), style),
                ];
                if !entry.detail.is_empty() && entry.detail != entry.model_name {
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(160, 160, 180))
                    } else {
                        Style::default().fg(Color::Rgb(110, 110, 130))
                    };
                    spans.push(Span::raw("  "));
                    spans.push(Span::styled(entry.detail.clone(), detail_style));
                }
                ListItem::new(Line::from(spans))
            })
            .collect();

        let model_count = filtered.len();
        let list = List::new(items).style(Style::default().bg(Color::Rgb(20, 20, 28)));
        frame.render_widget(list, body[0]);

        let mut detail_lines = Vec::new();
        if let Some(entry) = selector.current() {
            detail_lines.push(Line::from(Span::styled(
                entry.display.clone(),
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(vec![
                Span::styled("Provider: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                Span::raw(entry.provider.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Model: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                Span::raw(entry.model_name.clone()),
            ]));
            if !entry.detail.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Inventory: ",
                        Style::default().fg(Color::Rgb(145, 170, 170)),
                    ),
                    Span::raw(entry.detail.clone()),
                ]));
            }
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Use Enter to switch immediately. Plain typing keeps refining the list without triggering actions.",
            ));
        } else if selector.query.trim().is_empty() {
            detail_lines.push(Line::from(Span::styled(
                chrome.title.to_string(),
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Browse models with fuzzy search over provider, model name, and discovery source.",
            ));
            if let Some(note) = chrome.status_note {
                detail_lines.push(Line::from(""));
                detail_lines.push(Line::from(note.to_string()));
            }
        } else {
            detail_lines.push(Line::from("No models matched this query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a broader provider name or a shorter model fragment.",
            ));
        }
        if self.detail_fullscreen_active(detail_surface) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &selector.query,
                    header: BrowserChrome {
                        title: chrome.title,
                        placeholder: chrome.placeholder,
                        icon: "◈",
                        icon_color: Color::Cyan,
                        border_color: Color::Cyan,
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Cyan,
                        focused: true,
                        requested_scroll: self.detail_fullscreen_scroll(detail_surface),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Cyan)),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Cyan)),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Cyan),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Cyan)),
                        Span::styled("select  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Cyan)),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Cyan)),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }
        self.render_standard_split_detail(
            frame,
            body[1],
            detail_surface,
            Color::Cyan,
            detail_lines,
        );

        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Cyan)),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Cyan)),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Cyan));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Cyan));
        help_spans.extend([
            Span::styled("Enter ", Style::default().fg(Color::Cyan)),
            Span::styled("select  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Cyan)),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Cyan)),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{model_count} {} · {} pane",
                    chrome.count_label,
                    self.simple_split_focus_label(detail_surface)
                ),
                Style::default().fg(Color::Rgb(80, 80, 100)),
            ),
            Span::styled(
                chrome
                    .status_note
                    .map(|note| format!("  {note}"))
                    .unwrap_or_default(),
                Style::default().fg(Color::Yellow),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    /// Render the full-screen model selector overlay.
    fn render_model_selector(&self, frame: &mut Frame, area: Rect) {
        let base_title = match self.model_selector_target {
            ModelSelectorTarget::Primary => "Select Model",
            ModelSelectorTarget::Cheap => "Select Cheap Model",
            ModelSelectorTarget::MoaAggregator => "Select MoA Aggregator",
        };
        let title = if self.model_selector_refresh_in_flight {
            format!("{base_title} · refreshing live inventory")
        } else {
            base_title.to_string()
        };
        let placeholder = if self.model_selector_refresh_in_flight {
            "Type to filter models... live discovery updates in place (Esc to cancel)"
        } else {
            "Type to filter models... (Esc to cancel)"
        };
        self.render_model_like_selector(
            frame,
            area,
            &self.model_selector,
            DetailSurface::ModelSelector,
            SelectorChrome {
                title: &title,
                placeholder,
                count_label: "models",
                status_note: self
                    .model_selector_refresh_in_flight
                    .then_some("live discovery running"),
            },
        );
    }

    /// Render the full-screen vision-model selector overlay.
    fn render_vision_model_selector(&self, frame: &mut Frame, area: Rect) {
        self.render_model_like_selector(
            frame,
            area,
            &self.vision_model_selector,
            DetailSurface::VisionModelSelector,
            SelectorChrome {
                title: "Select Vision Model",
                placeholder: "Type to filter vision backends... (Esc to cancel)",
                count_label: "options",
                status_note: None,
            },
        );
    }

    fn render_image_model_selector(&self, frame: &mut Frame, area: Rect) {
        self.render_model_like_selector(
            frame,
            area,
            &self.image_model_selector,
            DetailSurface::ImageModelSelector,
            SelectorChrome {
                title: "Select Image Model",
                placeholder: "Type to filter image-generation backends... (Esc to cancel)",
                count_label: "image backends",
                status_note: None,
            },
        );
    }

    fn render_moa_reference_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let (title, placeholder, action_hint, count_hint) = match self.moa_reference_selector_mode {
            MoaReferenceSelectorMode::EditRoster => (
                " Select MoA Experts ",
                "Type to filter expert models…",
                "Space toggle  ",
                format!("{} selected", self.moa_reference_selected.len()),
            ),
            MoaReferenceSelectorMode::AddExpert => (
                " Add MoA Expert ",
                "Type to find an expert to add…",
                "Enter add  ",
                format!("{} configured", self.moa_reference_selected.len()),
            ),
            MoaReferenceSelectorMode::RemoveExpert => (
                " Remove MoA Expert ",
                "Type to find a configured expert…",
                "Enter remove  ",
                format!("{} configured", self.moa_reference_selected.len()),
            ),
        };

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(1),
            ])
            .split(area);

        let search_text = if self.moa_reference_selector.query.is_empty() {
            placeholder.to_string()
        } else {
            self.moa_reference_selector.query.clone()
        };
        let search_style = if self.moa_reference_selector.query.is_empty() {
            Style::default().fg(Color::DarkGray)
        } else {
            Style::default().fg(Color::White)
        };
        let search = Paragraph::new(Line::from(vec![
            Span::styled("  🧠 ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled(search_text, search_style),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(130, 210, 255)))
                .title(title),
        );
        frame.render_widget(search, chunks[0]);

        let filtered = &self.moa_reference_selector.filtered;
        let selected = self.moa_reference_selector.selected;
        let max_visible = chunks[1].height as usize;
        let scroll_start = if selected >= max_visible {
            selected - max_visible + 1
        } else {
            0
        };

        let items: Vec<ListItem> = filtered
            .iter()
            .skip(scroll_start)
            .take(max_visible)
            .enumerate()
            .map(|(vis_idx, &entry_idx)| {
                let entry = &self.moa_reference_selector.items[entry_idx];
                let is_selected = vis_idx + scroll_start == selected;
                let is_checked = self.moa_reference_selected.contains(&entry.display);
                let bg = if is_selected {
                    Color::Rgb(22, 36, 44)
                } else {
                    Color::Rgb(18, 22, 28)
                };
                let prefix = match self.moa_reference_selector_mode {
                    MoaReferenceSelectorMode::EditRoster => {
                        format!("  [{}] ", if is_checked { "x" } else { " " })
                    }
                    MoaReferenceSelectorMode::AddExpert => "  [+] ".to_string(),
                    MoaReferenceSelectorMode::RemoveExpert => "  [-] ".to_string(),
                };
                let prefix_color = match self.moa_reference_selector_mode {
                    MoaReferenceSelectorMode::EditRoster => {
                        if is_checked {
                            Color::Green
                        } else {
                            Color::DarkGray
                        }
                    }
                    MoaReferenceSelectorMode::AddExpert => Color::Rgb(120, 220, 160),
                    MoaReferenceSelectorMode::RemoveExpert => Color::Rgb(255, 130, 130),
                };
                ListItem::new(Line::from(vec![
                    selector_marker(is_selected, Color::Rgb(130, 210, 255), Some(bg)),
                    Span::styled(prefix, Style::default().bg(bg).fg(prefix_color)),
                    Span::styled(
                        unicode_pad_right(&entry.display, 38),
                        Style::default().bg(bg).fg(if is_selected {
                            Color::Rgb(130, 210, 255)
                        } else {
                            Color::Rgb(220, 232, 240)
                        }),
                    ),
                    Span::styled(
                        unicode_trunc(&entry.detail, 44),
                        Style::default().bg(bg).fg(Color::Rgb(125, 140, 150)),
                    ),
                ]))
            })
            .collect();
        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(18, 22, 28))),
            chunks[1],
        );

        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Cyan)),
            Span::styled("navigate  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                if self.moa_reference_selector_mode == MoaReferenceSelectorMode::EditRoster {
                    "Space "
                } else {
                    "Enter "
                },
                Style::default().fg(Color::Cyan),
            ),
            Span::styled(action_hint, Style::default().fg(Color::DarkGray)),
            Span::styled(
                if self.moa_reference_selector_mode == MoaReferenceSelectorMode::EditRoster {
                    "Enter "
                } else {
                    ""
                },
                Style::default().fg(Color::Cyan),
            ),
            Span::styled(
                if self.moa_reference_selector_mode == MoaReferenceSelectorMode::EditRoster {
                    "save  "
                } else {
                    ""
                },
                Style::default().fg(Color::DarkGray),
            ),
            Span::styled("Esc ", Style::default().fg(Color::Cyan)),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
            Span::styled(count_hint, Style::default().fg(Color::Rgb(80, 80, 100))),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn browser_overlay_chunks(area: Rect) -> std::rc::Rc<[Rect]> {
        Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(1),
            ])
            .split(area)
    }

    fn browser_body_chunks(area: Rect) -> std::rc::Rc<[Rect]> {
        Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
            .split(area)
    }

    fn browser_list_visible_rows(area: Rect, bordered: bool) -> usize {
        let reserved_rows = if bordered { 2 } else { 0 };
        area.height.saturating_sub(reserved_rows).max(1) as usize
    }

    fn browser_scroll_start(selected: usize, max_visible: usize) -> usize {
        if selected >= max_visible {
            selected - max_visible + 1
        } else {
            0
        }
    }

    fn best_session_message_selection(
        items: &[SessionMessageEntry],
        matched_role: Option<&str>,
        matched_snippet: Option<&str>,
    ) -> usize {
        let mut best_index = 0usize;
        let mut best_score = 0usize;

        for (index, entry) in items.iter().enumerate() {
            let score = entry.browser_match_score(matched_role, matched_snippet);
            if score > best_score {
                best_score = score;
                best_index = index;
            }
        }

        best_index
    }

    fn render_browser_header(
        &self,
        frame: &mut Frame,
        area: Rect,
        query: &str,
        chrome: BrowserChrome<'_>,
    ) {
        let search_text = if query.is_empty() {
            chrome.placeholder.to_string()
        } else {
            query.to_string()
        };
        let search_style = if query.is_empty() {
            Style::default().fg(Color::DarkGray)
        } else {
            Style::default().fg(Color::White)
        };
        let search = Paragraph::new(Line::from(vec![
            Span::styled(
                format!("  {} ", chrome.icon),
                Style::default().fg(chrome.icon_color),
            ),
            Span::styled(search_text, search_style),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(chrome.border_color))
                .title(format!(" {} ", chrome.title)),
        );
        frame.render_widget(search, area);
    }

    fn render_scrollable_browser_detail(
        &self,
        frame: &mut Frame,
        area: Rect,
        chrome: ScrollableDetailChrome<'_>,
        detail_lines: Vec<Line<'static>>,
    ) {
        let border_style = if chrome.focused {
            Style::default()
                .fg(chrome.border_color)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Rgb(60, 80, 84))
        };
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(border_style)
            .title(format!(" {} ", chrome.title));
        let inner = block.inner(area);
        frame.render_widget(block, area);

        if inner.width == 0 || inner.height == 0 {
            return;
        }

        let visual_rows_for_width = |content_width: usize| -> u16 {
            detail_lines
                .iter()
                .map(|line| {
                    let width = line.width();
                    if width == 0 {
                        1
                    } else {
                        width.div_ceil(content_width.max(1)) as u16
                    }
                })
                .sum()
        };

        let full_width = inner.width.max(1) as usize;
        let mut content_width = full_width;
        let mut visual_rows = visual_rows_for_width(content_width);
        let needs_scrollbar = visual_rows > inner.height;
        if needs_scrollbar && inner.width > 1 {
            content_width = inner.width.saturating_sub(1).max(1) as usize;
            visual_rows = visual_rows_for_width(content_width);
        }
        let max_scroll = visual_rows.saturating_sub(inner.height);
        let scroll = chrome.requested_scroll.min(max_scroll);

        let paragraph = Paragraph::new(Text::from(detail_lines))
            .wrap(Wrap { trim: false })
            .scroll((scroll, 0));
        let content_area = Rect {
            x: inner.x,
            y: inner.y,
            width: if needs_scrollbar && inner.width > 1 {
                inner.width.saturating_sub(1)
            } else {
                inner.width
            },
            height: inner.height,
        };
        frame.render_widget(paragraph, content_area);

        if needs_scrollbar {
            let mut scrollbar_state =
                ScrollbarState::new(max_scroll as usize).position(scroll as usize);
            let scrollbar_area = Rect {
                x: inner.right().saturating_sub(1),
                y: inner.y,
                width: 1,
                height: inner.height,
            };
            frame.render_stateful_widget(
                Scrollbar::new(ScrollbarOrientation::VerticalRight)
                    .begin_symbol(None)
                    .end_symbol(None)
                    .track_symbol(Some("│"))
                    .thumb_symbol("█"),
                scrollbar_area,
                &mut scrollbar_state,
            );
        }
    }

    fn detail_fullscreen_active(&self, surface: DetailSurface) -> bool {
        self.detail_fullscreen
            .is_some_and(|state| state.surface == surface)
    }

    fn split_detail_scroll(&self, surface: DetailSurface) -> u16 {
        match surface {
            DetailSurface::GatewayBrowser => self.gateway_browser_pane.scroll,
            DetailSurface::LogBrowser => self.log_browser_pane.scroll,
            DetailSurface::SessionBrowser => self.session_browser_pane.scroll,
            DetailSurface::SessionInspector => self.session_inspector.pane.scroll,
            DetailSurface::LogInspector => self.log_inspector.pane.scroll,
            _ => self.simple_detail_state.scroll_for(surface),
        }
    }

    fn set_split_detail_scroll(&mut self, surface: DetailSurface, scroll: u16) {
        match surface {
            DetailSurface::GatewayBrowser => self.gateway_browser_pane.scroll = scroll,
            DetailSurface::LogBrowser => self.log_browser_pane.scroll = scroll,
            DetailSurface::SessionBrowser => self.session_browser_pane.scroll = scroll,
            DetailSurface::SessionInspector => self.session_inspector.pane.scroll = scroll,
            DetailSurface::LogInspector => self.log_inspector.pane.scroll = scroll,
            _ => self.simple_detail_state.set_scroll(surface, scroll),
        }
    }

    fn reset_split_detail_scroll(&mut self, surface: DetailSurface) {
        match surface {
            DetailSurface::GatewayBrowser => self.gateway_browser_pane.reset_scroll(),
            DetailSurface::LogBrowser => self.log_browser_pane.reset_scroll(),
            DetailSurface::SessionBrowser => self.session_browser_pane.reset_scroll(),
            DetailSurface::SessionInspector => self.session_inspector.pane.reset_scroll(),
            DetailSurface::LogInspector => self.log_inspector.pane.reset_scroll(),
            _ => self.simple_detail_state.reset(surface),
        }
        self.needs_redraw = true;
    }

    fn page_up_split_detail(&mut self, surface: DetailSurface, step: u16) {
        let scroll = self
            .split_detail_scroll(surface)
            .saturating_sub(step.max(1));
        self.set_split_detail_scroll(surface, scroll);
        self.needs_redraw = true;
    }

    fn page_down_split_detail(&mut self, surface: DetailSurface, step: u16) {
        let scroll = self
            .split_detail_scroll(surface)
            .saturating_add(step.max(1));
        self.set_split_detail_scroll(surface, scroll);
        self.needs_redraw = true;
    }

    fn render_standard_split_detail(
        &self,
        frame: &mut Frame,
        area: Rect,
        surface: DetailSurface,
        border_color: Color,
        detail_lines: Vec<Line<'static>>,
    ) {
        self.render_scrollable_browser_detail(
            frame,
            area,
            ScrollableDetailChrome {
                title: "Details",
                border_color,
                focused: if Self::simple_split_focus_supported(surface) {
                    self.simple_split_focus(surface) == SplitPaneFocus::Detail
                } else {
                    true
                },
                requested_scroll: self.split_detail_scroll(surface),
            },
            detail_lines,
        );
    }

    fn toggle_detail_fullscreen(&mut self, surface: DetailSurface, initial_scroll: u16) {
        if self.detail_fullscreen_active(surface) {
            let scroll = self.detail_fullscreen_scroll(surface);
            self.set_split_detail_scroll(surface, scroll);
            self.detail_fullscreen = None;
        } else {
            self.detail_fullscreen = Some(DetailFullscreenState {
                surface,
                scroll: initial_scroll,
            });
        }
        self.needs_redraw = true;
    }

    fn close_detail_fullscreen(&mut self, surface: DetailSurface) -> bool {
        if self.detail_fullscreen_active(surface) {
            let scroll = self.detail_fullscreen_scroll(surface);
            self.set_split_detail_scroll(surface, scroll);
            self.detail_fullscreen = None;
            self.needs_redraw = true;
            true
        } else {
            false
        }
    }

    fn reset_detail_fullscreen_scroll(&mut self, surface: DetailSurface) {
        if let Some(state) = self.detail_fullscreen.as_mut()
            && state.surface == surface
        {
            state.scroll = 0;
            self.needs_redraw = true;
        }
    }

    fn page_up_detail_fullscreen(&mut self, surface: DetailSurface) {
        if let Some(state) = self.detail_fullscreen.as_mut()
            && state.surface == surface
        {
            state.scroll = state.scroll.saturating_sub(8);
            self.needs_redraw = true;
        }
    }

    fn page_down_detail_fullscreen(&mut self, surface: DetailSurface) {
        if let Some(state) = self.detail_fullscreen.as_mut()
            && state.surface == surface
        {
            state.scroll = state.scroll.saturating_add(8);
            self.needs_redraw = true;
        }
    }

    fn page_simple_selector_list(&mut self, surface: DetailSurface, intent: PagingIntent) {
        match surface {
            DetailSurface::ModelSelector => match intent {
                PagingIntent::Up => self.model_selector.page_up(),
                PagingIntent::Down => self.model_selector.page_down(),
            },
            DetailSurface::VisionModelSelector => match intent {
                PagingIntent::Up => self.vision_model_selector.page_up(),
                PagingIntent::Down => self.vision_model_selector.page_down(),
            },
            DetailSurface::ImageModelSelector => match intent {
                PagingIntent::Up => self.image_model_selector.page_up(),
                PagingIntent::Down => self.image_model_selector.page_down(),
            },
            DetailSurface::McpSelector => match intent {
                PagingIntent::Up => self.mcp_selector.page_up(),
                PagingIntent::Down => self.mcp_selector.page_down(),
            },
            DetailSurface::RemoteMcpBrowser => match intent {
                PagingIntent::Up => self.remote_mcp_browser.selector.page_up(),
                PagingIntent::Down => self.remote_mcp_browser.selector.page_down(),
            },
            DetailSurface::RemoteSkillBrowser => match intent {
                PagingIntent::Up => self.remote_skill_browser.selector.page_up(),
                PagingIntent::Down => self.remote_skill_browser.selector.page_down(),
            },
            DetailSurface::RemotePluginBrowser => match intent {
                PagingIntent::Up => self.remote_plugin_browser.selector.page_up(),
                PagingIntent::Down => self.remote_plugin_browser.selector.page_down(),
            },
            DetailSurface::ProfileSelector => match intent {
                PagingIntent::Up => self.profile_selector.page_up(),
                PagingIntent::Down => self.profile_selector.page_down(),
            },
            DetailSurface::SkillSelector => match intent {
                PagingIntent::Up => self.skill_selector.page_up(),
                PagingIntent::Down => self.skill_selector.page_down(),
            },
            DetailSurface::ToolManager => match intent {
                PagingIntent::Up => self.tool_manager.page_up(),
                PagingIntent::Down => self.tool_manager.page_down(),
            },
            DetailSurface::PluginToggle => match intent {
                PagingIntent::Up => self.plugin_toggle.page_up(),
                PagingIntent::Down => self.plugin_toggle.page_down(),
            },
            DetailSurface::ConfigSelector => match intent {
                PagingIntent::Up => self.config_selector.page_up(),
                PagingIntent::Down => self.config_selector.page_down(),
            },
            DetailSurface::GatewayBrowser
            | DetailSurface::SessionBrowser
            | DetailSurface::SessionInspector
            | DetailSurface::LogBrowser
            | DetailSurface::LogInspector => return,
        }

        self.reset_split_detail_scroll(surface);
        self.reset_detail_fullscreen_scroll(surface);
        self.needs_redraw = true;
    }

    fn apply_simple_selector_paging(&mut self, surface: DetailSurface, intent: PagingIntent) {
        if self.detail_fullscreen_active(surface) {
            match intent {
                PagingIntent::Up => self.page_up_detail_fullscreen(surface),
                PagingIntent::Down => self.page_down_detail_fullscreen(surface),
            }
        } else if self.simple_split_detail_focused(surface) {
            match intent {
                PagingIntent::Up => self.page_up_split_detail(surface, 8),
                PagingIntent::Down => self.page_down_split_detail(surface, 8),
            }
        } else {
            self.page_simple_selector_list(surface, intent);
        }
    }

    fn detail_fullscreen_scroll(&self, surface: DetailSurface) -> u16 {
        self.detail_fullscreen
            .filter(|state| state.surface == surface)
            .map_or(0, |state| state.scroll)
    }

    fn render_fullscreen_browser_detail(
        &self,
        frame: &mut Frame,
        area: Rect,
        chrome: FullscreenBrowserChrome<'_>,
        detail_lines: Vec<Line<'static>>,
    ) {
        frame.render_widget(Clear, area);
        let chunks = Self::browser_overlay_chunks(area);
        let header_title = format!("{} · Detail", chrome.header.title);
        self.render_browser_header(
            frame,
            chunks[0],
            chrome.query,
            BrowserChrome {
                title: &header_title,
                placeholder: chrome.header.placeholder,
                icon: chrome.header.icon,
                icon_color: chrome.header.icon_color,
                border_color: chrome.header.border_color,
            },
        );
        self.render_scrollable_browser_detail(frame, chunks[1], chrome.detail, detail_lines);
        frame.render_widget(
            Paragraph::new(self.normalize_paging_help_line(chrome.help)),
            chunks[2],
        );
    }

    fn render_mcp_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.mcp_selector.query,
            BrowserChrome {
                title: "MCP Browser",
                placeholder: "Search configured MCP servers and the official catalog.",
                icon: "⛓",
                icon_color: Color::Rgb(110, 220, 210),
                border_color: Color::Rgb(110, 220, 210),
            },
        );

        let max_visible = body[0].height as usize;
        let filtered = &self.mcp_selector.filtered;
        let selected = self.mcp_selector.selected;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if self.mcp_selector.query.trim().is_empty() {
                "  No configured servers or catalog entries are available."
            } else {
                "  No MCP entries matched this query."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &preset_idx)| {
                    let entry = &self.mcp_selector.items[preset_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(24, 36, 44)
                    } else {
                        Color::Rgb(18, 24, 26)
                    };
                    let row_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(110, 220, 210))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(210, 220, 220))
                    };
                    let source_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(145, 170, 170))
                    } else {
                        Style::default().fg(Color::Rgb(100, 120, 120))
                    };
                    let action_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(210, 240, 175))
                    } else {
                        Style::default().fg(Color::Rgb(135, 165, 110))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(160, 180, 180))
                    } else {
                        Style::default().fg(Color::Rgb(120, 140, 140))
                    };
                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(110, 220, 210), Some(bg)),
                        Span::styled(format!("  {:<12}", entry.source), source_style),
                        Span::styled(format!("{:<9}", entry.action_label), action_style),
                        Span::styled(unicode_trunc(&entry.display_title(), 38), row_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.detail, 38), detail_style),
                    ]))
                })
                .collect()
        };

        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(18, 24, 26))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.mcp_selector.current() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", entry.source),
                    Style::default()
                        .fg(Color::Rgb(110, 220, 210))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(entry.title.clone()),
            ]));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(vec![
                Span::styled(
                    "Default action: ",
                    Style::default().fg(Color::Rgb(145, 170, 170)),
                ),
                Span::raw(entry.action_label.clone()),
            ]));
            detail_lines.push(Line::from(""));
            for line in entry.detail_view.lines() {
                detail_lines.push(Line::from(line.to_string()));
            }
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(entry.detail_actions_line()));
        } else if self.mcp_selector.query.trim().is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "MCP Browser",
                Style::default()
                    .fg(Color::Rgb(110, 220, 210))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from("Browse two sources in one place:"));
            detail_lines.push(Line::from(
                "- configured MCP servers from your local config",
            ));
            detail_lines.push(Line::from(
                "- the cached official MCP catalog with installable presets",
            ));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Use fuzzy search to jump by server name, package, tags, transport, env vars, or docs source.",
            ));
        } else {
            detail_lines.push(Line::from("No results for the current query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try broader terms like github, browser, database, time, filesystem, oauth, or http.",
            ));
        }

        if self.detail_fullscreen_active(DetailSurface::McpSelector) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.mcp_selector.query,
                    header: BrowserChrome {
                        title: "MCP Browser",
                        placeholder: "Search configured MCP servers and the official catalog.",
                        icon: "⛓",
                        icon_color: Color::Rgb(110, 220, 210),
                        border_color: Color::Rgb(110, 220, 210),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(110, 220, 210),
                        focused: true,
                        requested_scroll: self.detail_fullscreen_scroll(DetailSurface::McpSelector),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(110, 220, 210)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Space ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("toggle  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::McpSelector,
            Color::Rgb(110, 220, 210),
            detail_lines,
        );

        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend([
            Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Space ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("toggle active  ", Style::default().fg(Color::DarkGray)),
            Span::styled("I ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("install  ", Style::default().fg(Color::DarkGray)),
            Span::styled("T ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("test  ", Style::default().fg(Color::DarkGray)),
            Span::styled("C ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("check  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("V ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("view  ", Style::default().fg(Color::DarkGray)),
            Span::styled("D ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("remove  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("refresh  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} entries · {} pane",
                    filtered.len(),
                    self.simple_split_focus_label(DetailSurface::McpSelector)
                ),
                Style::default().fg(Color::Rgb(100, 120, 120)),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn render_remote_mcp_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);

        let browser = &self.remote_mcp_browser;
        let query = browser.current_query();
        self.render_browser_header(
            frame,
            chunks[0],
            &browser.selector.query,
            BrowserChrome {
                title: if browser.inflight_request_id.is_some() {
                    "Remote MCP · Searching…"
                } else {
                    "Remote MCP"
                },
                placeholder: "Type to search official MCP sources and the official MCP Registry",
                icon: "⛓",
                icon_color: Color::Rgb(90, 190, 220),
                border_color: if browser.inflight_request_id.is_some() {
                    Color::Rgb(110, 220, 210)
                } else {
                    Color::Rgb(90, 190, 220)
                },
            },
        );

        let filtered = &browser.selector.filtered;
        let selected = browser.selector.selected;
        let max_visible = body[0].height as usize;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if query.is_empty() {
                "  Start typing to search official MCP sources."
            } else if browser.inflight_request_id.is_some() {
                "  Searching official MCP sources…"
            } else {
                "  No MCP servers matched this query."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &browser.selector.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(24, 36, 44)
                    } else {
                        Color::Rgb(18, 24, 26)
                    };
                    let source_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(145, 170, 170))
                    } else {
                        Style::default().fg(Color::Rgb(100, 120, 120))
                    };
                    let action_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(210, 240, 175))
                    } else {
                        Style::default().fg(Color::Rgb(135, 165, 110))
                    };
                    let main_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(110, 220, 210))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(210, 220, 220))
                    };
                    let desc_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(160, 180, 180))
                    } else {
                        Style::default().fg(Color::Rgb(120, 140, 140))
                    };
                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(110, 220, 210), Some(bg)),
                        Span::styled(format!("  {:<12}", entry.source_label), source_style),
                        Span::styled(format!("{:<8}", entry.action().label()), action_style),
                        Span::styled(unicode_trunc(&entry.id, 40), main_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.description, 34), desc_style),
                    ]))
                })
                .collect()
        };

        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(18, 24, 26))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = browser.selector.current() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", entry.source_label),
                    Style::default()
                        .fg(Color::Rgb(110, 220, 210))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(entry.id.clone()),
            ]));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(entry.description.clone()));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(vec![
                Span::styled("Origin: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                Span::raw(entry.origin.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Action: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                Span::raw(match entry.action() {
                    RemoteMcpAction::Install => "Default action: install",
                    RemoteMcpAction::View => "Default action: view",
                }),
            ]));
            if let Some(transport) = &entry.transport {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Transport: ",
                        Style::default().fg(Color::Rgb(145, 170, 170)),
                    ),
                    Span::raw(transport.clone()),
                ]));
            }
            if let Some(crate::mcp_catalog::McpInstallPlan::Http {
                required_headers, ..
            }) = &entry.install
                && !required_headers.is_empty()
            {
                detail_lines.push(Line::from(vec![
                    Span::styled("Auth: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                    Span::raw(format!(
                        "manual header setup required: {}",
                        required_headers.join(", ")
                    )),
                ]));
            }
            if !entry.tags.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled("Tags: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                    Span::raw(entry.tags.join(", ")),
                ]));
            }
            if entry.install.is_none() {
                detail_lines.push(Line::from(""));
                detail_lines.push(Line::from(Span::styled(
                    "This entry is searchable but not auto-installable with the current EdgeCrab MCP transport support.",
                    Style::default().fg(Color::Rgb(255, 180, 120)),
                )));
            }
        } else if query.is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "Official Sources",
                Style::default()
                    .fg(Color::Rgb(110, 220, 210))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from("- MCP Reference"));
            detail_lines.push(Line::from("- Official Apps"));
            detail_lines.push(Line::from("- Archived"));
            detail_lines.push(Line::from("- MCP Registry"));
        } else if browser.inflight_request_id.is_some() {
            detail_lines.push(Line::from("Searching official MCP sources…"));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "The selector stays responsive while live registry results are fetched in the background.",
            ));
        } else {
            detail_lines.push(Line::from("No results for the current query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a broader term like github, database, browser, time, or auth.",
            ));
        }

        if !browser.notices.is_empty() {
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(Span::styled(
                "Source Notes",
                Style::default()
                    .fg(Color::Rgb(255, 191, 0))
                    .add_modifier(Modifier::BOLD),
            )));
            for notice in &browser.notices {
                detail_lines.push(Line::from(format!("- {notice}")));
            }
        }

        if self.detail_fullscreen_active(DetailSurface::RemoteMcpBrowser) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &browser.selector.query,
                    header: BrowserChrome {
                        title: if browser.inflight_request_id.is_some() {
                            "Remote MCP · Searching…"
                        } else {
                            "Remote MCP"
                        },
                        placeholder:
                            "Type to search official MCP sources and the official MCP Registry",
                        icon: "⛓",
                        icon_color: Color::Rgb(90, 190, 220),
                        border_color: if browser.inflight_request_id.is_some() {
                            Color::Rgb(110, 220, 210)
                        } else {
                            Color::Rgb(90, 190, 220)
                        },
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(90, 190, 220),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::RemoteMcpBrowser),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(110, 220, 210)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::RemoteMcpBrowser,
            Color::Rgb(90, 190, 220),
            detail_lines,
        );

        let status_text = if browser.inflight_request_id.is_some() {
            "searching"
        } else if !query.is_empty() && filtered.is_empty() {
            "no matches"
        } else {
            "matches"
        };
        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend([
            Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
            Span::styled("I ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("install  ", Style::default().fg(Color::DarkGray)),
            Span::styled("V ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("view  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("refresh  ", Style::default().fg(Color::DarkGray)),
            Span::styled("L ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("local browser  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} {} · {} pane",
                    filtered.len(),
                    status_text,
                    self.simple_split_focus_label(DetailSurface::RemoteMcpBrowser)
                ),
                Style::default().fg(Color::Rgb(100, 120, 120)),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn profile_browser_title(&self) -> &'static str {
        match self.profile_detail_mode {
            ProfileDetailMode::Summary => "Browse Profiles",
            ProfileDetailMode::Config => "Browse Profiles · Config",
            ProfileDetailMode::Soul => "Browse Profiles · SOUL",
            ProfileDetailMode::Memory => "Browse Profiles · Memory",
            ProfileDetailMode::Tools => "Browse Profiles · Tools",
            ProfileDetailMode::Help => "Browse Profiles · Help",
        }
    }

    fn profile_help_text(&self) -> String {
        [
            "Profiles",
            "",
            "Enter  switch to the selected profile",
            "V      summary view",
            "C      config.yaml view",
            "S      SOUL.md view",
            "M      memory files view",
            "T      tool policy view",
            "A      alias editor",
            "E      export editor",
            "D      delete confirmation",
            "N      create editor",
            "I      import editor",
            "O      rename editor",
            "Tab / Right  next detail tab",
            "Shift-Tab / Left  previous detail tab",
            "Home / End  jump to first or last match",
            "PgUp / PgDn  jump through matches in split view",
            "Z      toggle split/full detail",
            "Esc    close overlay",
            "",
            "Slash command entry points:",
            "/profile",
            "/profile show <name>",
            "/profile config <name>",
            "/profile soul <name>",
            "/profile memory <name>",
            "/profile tools <name>",
            "/profile create ...",
            "/profile rename ...",
            "/profile delete ...",
            "/profile alias ...",
            "/profile export ...",
            "/profile import ...",
        ]
        .join("\n")
    }

    fn render_profile_detail_text(&self, entry: Option<&ProfileEntry>) -> String {
        let Some(entry) = entry else {
            return match self.profile_detail_mode {
                ProfileDetailMode::Help => self.profile_help_text(),
                _ => "No profiles matched this query.".into(),
            };
        };

        let manager = ProfileManager::new();
        match self.profile_detail_mode {
            ProfileDetailMode::Summary => {
                format!("{}\n\n{}", entry.detail_view, entry.detail_actions_line())
            }
            ProfileDetailMode::Config => manager
                .render_config(&entry.name)
                .unwrap_or_else(|err| format!("profile config: {err}")),
            ProfileDetailMode::Soul => manager
                .render_soul(&entry.name)
                .unwrap_or_else(|err| format!("profile soul: {err}")),
            ProfileDetailMode::Memory => manager
                .render_memory(&entry.name)
                .unwrap_or_else(|err| format!("profile memory: {err}")),
            ProfileDetailMode::Tools => manager
                .render_tools_report(&entry.name)
                .unwrap_or_else(|err| format!("profile tools: {err}")),
            ProfileDetailMode::Help => {
                format!(
                    "{}\n\nCurrent selection: {}",
                    self.profile_help_text(),
                    entry.name
                )
            }
        }
    }

    /// Render the full-screen skill browser overlay.
    ///
    /// UX mirrors `render_model_selector` — same search-box + list + help-line
    /// layout — so users get a consistent experience between `/model` and `/skills`.
    fn render_profile_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.profile_selector.query,
            BrowserChrome {
                title: self.profile_browser_title(),
                placeholder: "Search profiles by name, model, path, or status.",
                icon: "👤",
                icon_color: Color::Rgb(120, 205, 255),
                border_color: Color::Rgb(120, 205, 255),
            },
        );

        let max_visible = body[0].height as usize;
        let filtered = &self.profile_selector.filtered;
        let selected = self.profile_selector.selected;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = filtered
            .iter()
            .skip(scroll_start)
            .take(max_visible)
            .enumerate()
            .map(|(vis_idx, &profile_idx)| {
                let entry = &self.profile_selector.items[profile_idx];
                let is_selected = vis_idx + scroll_start == selected;
                let bg = if is_selected {
                    Color::Rgb(18, 36, 48)
                } else {
                    Color::Rgb(20, 20, 28)
                };
                ListItem::new(Line::from(vec![
                    selector_marker(is_selected, Color::Rgb(120, 205, 255), Some(bg)),
                    Span::styled(
                        format!("  {:<7}", entry.status_label()),
                        if is_selected {
                            Style::default().bg(bg).fg(Color::Rgb(165, 225, 255))
                        } else {
                            Style::default().fg(Color::Rgb(90, 130, 155))
                        },
                    ),
                    Span::styled(
                        format!("{:<8}", entry.kind_label()),
                        if is_selected {
                            Style::default().bg(bg).fg(Color::Rgb(110, 170, 210))
                        } else {
                            Style::default().fg(Color::Rgb(70, 100, 120))
                        },
                    ),
                    Span::styled(
                        unicode_trunc(&entry.display_title(), 24),
                        if is_selected {
                            Style::default()
                                .bg(bg)
                                .fg(Color::Rgb(180, 235, 255))
                                .add_modifier(Modifier::BOLD)
                        } else {
                            Style::default().fg(Color::Rgb(135, 200, 225))
                        },
                    ),
                    Span::raw("  "),
                    Span::styled(
                        unicode_trunc(&entry.detail, 48),
                        if is_selected {
                            Style::default().bg(bg).fg(Color::Rgb(135, 180, 205))
                        } else {
                            Style::default().fg(Color::Rgb(85, 110, 130))
                        },
                    ),
                ]))
            })
            .collect();
        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(20, 20, 28))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.profile_selector.current() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", entry.status_label().to_uppercase()),
                    Style::default()
                        .fg(Color::Rgb(120, 205, 255))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(entry.display_title()),
                Span::styled(
                    format!("  [{}]", self.profile_detail_mode.title()),
                    Style::default().fg(Color::Rgb(90, 150, 180)),
                ),
            ]));
        } else {
            detail_lines.push(Line::from(Span::styled(
                format!("Detail Mode: {}", self.profile_detail_mode.title()),
                Style::default()
                    .fg(Color::Rgb(120, 205, 255))
                    .add_modifier(Modifier::BOLD),
            )));
        }
        detail_lines.push(Line::from(""));
        for line in self
            .render_profile_detail_text(self.profile_selector.current())
            .lines()
        {
            detail_lines.push(Line::from(line.to_string()));
        }

        if self.detail_fullscreen_active(DetailSurface::ProfileSelector) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.profile_selector.query,
                    header: BrowserChrome {
                        title: self.profile_browser_title(),
                        placeholder: "Search profiles by name, model, path, or status.",
                        icon: "👤",
                        icon_color: Color::Rgb(120, 205, 255),
                        border_color: Color::Rgb(120, 205, 255),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(120, 205, 255),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::ProfileSelector),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(120, 205, 255)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("switch profile  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("C ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("config  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("S ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("SOUL  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("M ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("memory  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("T ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("tools  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("V ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("view summary  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("A/E ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("alias/export  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("D/N/I/O ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled(
                            "delete/new/import/rename  ",
                            Style::default().fg(Color::DarkGray),
                        ),
                        Span::styled("Tab/←→ ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("cycle views  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Home/End ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("jump  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(120, 205, 255))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::ProfileSelector,
            Color::Rgb(120, 205, 255),
            detail_lines,
        );

        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
            self.paging_key_help_span(Color::Rgb(120, 205, 255)),
            Span::styled("jump list  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("switch  ", Style::default().fg(Color::DarkGray)),
            Span::styled("C/S/M/T ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("inspect  ", Style::default().fg(Color::DarkGray)),
            Span::styled("V ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("summary  ", Style::default().fg(Color::DarkGray)),
            Span::styled("A/E ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("alias/export  ", Style::default().fg(Color::DarkGray)),
            Span::styled("D/N/I/O ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("manage  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Tab/←→ ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("views  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Home/End ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("jump  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("refresh  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(120, 205, 255))),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!("{} profile(s)", filtered.len()),
                Style::default().fg(Color::Rgb(80, 100, 120)),
            ),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn render_skill_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.skill_selector.query,
            BrowserChrome {
                title: "Browse Skills",
                placeholder: "Search local skills by name, category, path, preview, or support files.",
                icon: "📚",
                icon_color: Color::Rgb(255, 191, 0),
                border_color: Color::Rgb(255, 191, 0),
            },
        );

        let max_visible = body[0].height as usize;
        let filtered = &self.skill_selector.filtered;
        let selected = self.skill_selector.selected;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = filtered
            .iter()
            .skip(scroll_start)
            .take(max_visible)
            .enumerate()
            .map(|(vis_idx, &skill_idx)| {
                let entry = &self.skill_selector.items[skill_idx];
                let is_selected = vis_idx + scroll_start == selected;

                let bg = if is_selected {
                    Color::Rgb(40, 35, 15)
                } else {
                    Color::Rgb(20, 20, 28)
                };
                let state_style = if is_selected {
                    Style::default().bg(bg).fg(Color::Rgb(150, 140, 90))
                } else {
                    Style::default().fg(Color::Rgb(90, 80, 45))
                };
                let name_style = if is_selected {
                    Style::default()
                        .bg(bg)
                        .fg(Color::Rgb(255, 215, 0))
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(Color::Rgb(220, 200, 100))
                };
                let desc_style = if is_selected {
                    Style::default().bg(bg).fg(Color::Rgb(160, 150, 90))
                } else {
                    Style::default().fg(Color::Rgb(100, 95, 55))
                };

                ListItem::new(Line::from(vec![
                    selector_marker(is_selected, Color::Rgb(255, 191, 0), Some(bg)),
                    Span::styled(
                        format!("  {:<7}", if entry.active { "active" } else { "ready" }),
                        state_style,
                    ),
                    Span::styled(
                        format!("{:<7}", entry.kind_label()),
                        if is_selected {
                            Style::default().bg(bg).fg(Color::Rgb(120, 110, 60))
                        } else {
                            Style::default().fg(Color::Rgb(80, 75, 40))
                        },
                    ),
                    Span::styled(unicode_trunc(&entry.display_title(), 34), name_style),
                    Span::raw("  "),
                    Span::styled(unicode_trunc(&entry.list_detail(), 42), desc_style),
                ]))
            })
            .collect();

        let skill_count = filtered.len();
        let list = List::new(items).style(Style::default().bg(Color::Rgb(20, 20, 28)));
        frame.render_widget(list, body[0]);

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.skill_selector.current() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", if entry.active { "ACTIVE" } else { "READY" }),
                    Style::default()
                        .fg(Color::Rgb(255, 191, 0))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(entry.display_title()),
            ]));
            detail_lines.push(Line::from(""));
            for line in entry.detail_view.lines() {
                detail_lines.push(Line::from(line.to_string()));
            }
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(entry.detail_actions_line()));
        } else if self.skill_selector.query.trim().is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "Local Skills",
                Style::default()
                    .fg(Color::Rgb(255, 191, 0))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Browse installed skills with fuzzy search across names, categories, previews, and supporting files.",
            ));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Space toggles whether a skill is injected into your next prompt.",
            ));
            detail_lines.push(Line::from(
                "Enter inserts `/skill-name` into the composer if you want the explicit slash flow instead.",
            ));
        } else {
            detail_lines.push(Line::from("No local skills matched this query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a broader term, a category name, or press R to search remote sources.",
            ));
        }
        if self.detail_fullscreen_active(DetailSurface::SkillSelector) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.skill_selector.query,
                    header: BrowserChrome {
                        title: "Browse Skills",
                        placeholder:
                            "Search local skills by name, category, path, preview, or support files.",
                        icon: "📚",
                        icon_color: Color::Rgb(255, 191, 0),
                        border_color: Color::Rgb(255, 191, 0),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(255, 191, 0),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::SkillSelector),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(255, 191, 0))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(255, 191, 0))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(255, 191, 0)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Space ", Style::default().fg(Color::Rgb(255, 191, 0))),
                        Span::styled("toggle active  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(255, 191, 0))),
                        Span::styled("insert /skill  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(255, 191, 0))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(255, 191, 0))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }
        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::SkillSelector,
            Color::Rgb(255, 191, 0),
            detail_lines,
        );

        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(255, 191, 0)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(255, 191, 0)));
        help_spans.extend([
            Span::styled("Space ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("toggle active  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("insert /skill-name  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("remote search  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(255, 191, 0))),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{skill_count} skill(s) · {} pane",
                    self.simple_split_focus_label(DetailSurface::SkillSelector)
                ),
                Style::default().fg(Color::Rgb(80, 75, 40)),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn render_remote_skill_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);

        let browser = &self.remote_skill_browser;
        let query = browser.current_query();
        self.render_browser_header(
            frame,
            chunks[0],
            &browser.selector.query,
            BrowserChrome {
                title: if browser.inflight_request_id.is_some() {
                    "Remote Skills · Searching…"
                } else {
                    "Remote Skills"
                },
                placeholder: "Type to search remote skills from EdgeCrab, Hermes, OpenAI, Anthropic, skills.sh, or a well-known URL",
                icon: "🌐",
                icon_color: Color::Rgb(110, 220, 210),
                border_color: if browser.inflight_request_id.is_some() {
                    Color::Rgb(110, 220, 210)
                } else {
                    Color::Rgb(255, 191, 0)
                },
            },
        );

        let filtered = &browser.selector.filtered;
        let selected = browser.selector.selected;
        let max_visible = body[0].height as usize;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if query.is_empty() {
                "  Start typing to search remote skills."
            } else if browser.inflight_request_id.is_some() {
                "  Searching remote sources…"
            } else {
                "  No remote skills matched this query."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &browser.selector.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(24, 40, 44)
                    } else {
                        Color::Rgb(18, 24, 26)
                    };
                    let source_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(145, 170, 170))
                    } else {
                        Style::default().fg(Color::Rgb(100, 120, 120))
                    };
                    let action_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(210, 240, 175))
                    } else {
                        Style::default().fg(Color::Rgb(135, 165, 110))
                    };
                    let main_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(110, 220, 210))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(210, 220, 220))
                    };
                    let desc_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(160, 180, 180))
                    } else {
                        Style::default().fg(Color::Rgb(120, 140, 140))
                    };
                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(110, 220, 210), Some(bg)),
                        Span::styled(format!("  {:<11}", entry.source_label), source_style),
                        Span::styled(format!("{:<8}", entry.action.label()), action_style),
                        Span::styled(unicode_trunc(&entry.identifier, 44), main_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.description, 36), desc_style),
                    ]))
                })
                .collect()
        };

        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(18, 24, 26))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = browser.selector.current() {
            let status_line = match entry.action {
                RemoteSkillAction::Install => "Default action: install".to_string(),
                RemoteSkillAction::Update => format!(
                    "Default action: update ({})",
                    entry.installed_name.as_deref().unwrap_or(&entry.name)
                ),
                RemoteSkillAction::Replace => {
                    "Default action: replace existing local skill".to_string()
                }
            };
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", entry.source_label),
                    Style::default()
                        .fg(Color::Rgb(110, 220, 210))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    format!("[{}]", entry.trust_level),
                    Style::default().fg(Color::Rgb(160, 180, 180)),
                ),
            ]));
            detail_lines.push(Line::from(entry.identifier.clone()));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(entry.description.clone()));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(vec![
                Span::styled("Origin: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                Span::raw(entry.origin.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Action: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                Span::raw(status_line),
            ]));
            if !entry.tags.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled("Tags: ", Style::default().fg(Color::Rgb(145, 170, 170))),
                    Span::raw(entry.tags.join(", ")),
                ]));
            }
            if entry.action == RemoteSkillAction::Replace {
                detail_lines.push(Line::from(""));
                detail_lines.push(Line::from(Span::styled(
                    "Warning: this source would replace an existing local skill with the same name.",
                    Style::default().fg(Color::Rgb(255, 180, 120)),
                )));
            }
        } else if query.is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "Curated Sources",
                Style::default()
                    .fg(Color::Rgb(110, 220, 210))
                    .add_modifier(Modifier::BOLD),
            )));
            for source in edgecrab_tools::tools::skills_hub::curated_source_summaries() {
                detail_lines.push(Line::from(format!(
                    "- {} [{}]",
                    source.label, source.trust_level
                )));
            }
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Paste an https:// URL to search a .well-known skills endpoint too.",
            ));
        } else if browser.inflight_request_id.is_some() {
            detail_lines.push(Line::from("Searching remote sources…"));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "You can keep typing while results refresh. Slow or failing sources are reported here without blocking the UI.",
            ));
        } else {
            detail_lines.push(Line::from("No results for the current query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a broader term, a source name like 'edgecrab', or a full https:// URL for well-known skill discovery.",
            ));
        }

        if !browser.notices.is_empty() {
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(Span::styled(
                "Source Notes",
                Style::default()
                    .fg(Color::Rgb(255, 191, 0))
                    .add_modifier(Modifier::BOLD),
            )));
            for notice in &browser.notices {
                detail_lines.push(Line::from(format!("- {notice}")));
            }
        }

        if let Some(action) = &browser.action_in_flight {
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(Span::styled(
                format!("Running: {action}"),
                Style::default().fg(Color::Rgb(210, 240, 175)),
            )));
        }

        if self.detail_fullscreen_active(DetailSurface::RemoteSkillBrowser) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &browser.selector.query,
                    header: BrowserChrome {
                        title: if browser.inflight_request_id.is_some() {
                            "Remote Skills · Searching…"
                        } else {
                            "Remote Skills"
                        },
                        placeholder: "Type to search remote skills from EdgeCrab, Hermes, OpenAI, Anthropic, skills.sh, or a well-known URL",
                        icon: "🌐",
                        icon_color: Color::Rgb(110, 220, 210),
                        border_color: if browser.inflight_request_id.is_some() {
                            Color::Rgb(110, 220, 210)
                        } else {
                            Color::Rgb(255, 191, 0)
                        },
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(255, 191, 0),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(110, 220, 210)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::RemoteSkillBrowser,
            Color::Rgb(255, 191, 0),
            detail_lines,
        );

        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend([
            Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
            Span::styled("I ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("install/update  ", Style::default().fg(Color::DarkGray)),
            Span::styled("U ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("force update  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("refresh  ", Style::default().fg(Color::DarkGray)),
            Span::styled("L ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("local browser  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
        ]);
        let status_text = if browser.inflight_request_id.is_some() {
            "searching"
        } else if !query.is_empty() && filtered.is_empty() {
            "no matches"
        } else {
            "matches"
        };
        help_spans.push(Span::styled(
            format!(
                "{} {} · {} pane",
                filtered.len(),
                status_text,
                self.simple_split_focus_label(DetailSurface::RemoteSkillBrowser)
            ),
            Style::default().fg(Color::Rgb(100, 120, 120)),
        ));
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn render_remote_plugin_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);

        let browser = &self.remote_plugin_browser;
        let query = browser.current_query();
        let title = if let Some(source) = browser.source_filter.as_deref() {
            if browser.inflight_request_id.is_some() {
                format!("Remote Plugins · {source} · Searching…")
            } else {
                format!("Remote Plugins · {source}")
            }
        } else if browser.inflight_request_id.is_some() {
            "Remote Plugins · Searching…".to_string()
        } else {
            "Remote Plugins".to_string()
        };
        self.render_browser_header(
            frame,
            chunks[0],
            &browser.selector.query,
            BrowserChrome {
                title: &title,
                placeholder:
                    "Type to search official and configured plugin registries, or use /plugins search --source <name> <query>",
                icon: "🔌",
                icon_color: Color::Rgb(210, 190, 110),
                border_color: if browser.inflight_request_id.is_some() {
                    Color::Rgb(210, 190, 110)
                } else {
                    Color::Rgb(255, 191, 0)
                },
            },
        );

        let filtered = &browser.selector.filtered;
        let selected = browser.selector.selected;
        let max_visible = body[0].height as usize;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if query.is_empty() {
                "  Start typing to search remote plugins."
            } else if browser.inflight_request_id.is_some() {
                "  Searching remote plugin registries…"
            } else {
                "  No remote plugins matched this query."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &browser.selector.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(42, 34, 18)
                    } else {
                        Color::Rgb(24, 22, 16)
                    };
                    let source_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(205, 190, 140))
                    } else {
                        Style::default().fg(Color::Rgb(155, 140, 105))
                    };
                    let action_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(210, 240, 175))
                    } else {
                        Style::default().fg(Color::Rgb(135, 165, 110))
                    };
                    let kind_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(150, 165, 205))
                    } else {
                        Style::default().fg(Color::Rgb(110, 125, 160))
                    };
                    let main_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(255, 236, 175))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(220, 220, 210))
                    };
                    let desc_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(185, 180, 165))
                    } else {
                        Style::default().fg(Color::Rgb(140, 135, 125))
                    };
                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(255, 210, 110), Some(bg)),
                        Span::styled(format!("  {:<11}", entry.source_label), source_style),
                        Span::styled(format!("{:<8}", entry.action.label()), action_style),
                        Span::styled(unicode_pad_right(&entry.kind, 8), kind_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.identifier, 40), main_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.description, 28), desc_style),
                    ]))
                })
                .collect()
        };

        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(24, 22, 16))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = browser.selector.current() {
            let status_line = match entry.action {
                RemotePluginAction::Install => "Default action: install".to_string(),
                RemotePluginAction::Update => format!(
                    "Default action: update ({})",
                    entry.installed_name.as_deref().unwrap_or(&entry.name)
                ),
                RemotePluginAction::Replace => {
                    "Default action: replace existing local plugin".to_string()
                }
            };
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", entry.source_label),
                    Style::default()
                        .fg(Color::Rgb(255, 236, 175))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    format!("[{}]", entry.trust_level),
                    Style::default().fg(Color::Rgb(185, 180, 165)),
                ),
            ]));
            detail_lines.push(Line::from(entry.identifier.clone()));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(entry.description.clone()));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(vec![
                Span::styled("Kind: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(entry.kind.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Origin: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(entry.origin.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Action: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(status_line),
            ]));
            if !entry.requires_env.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Requires env: ",
                        Style::default().fg(Color::Rgb(205, 190, 140)),
                    ),
                    Span::raw(entry.requires_env.join(", ")),
                ]));
            }
            if !entry.tags.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled("Tags: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                    Span::raw(entry.tags.join(", ")),
                ]));
            }
            if entry.action == RemotePluginAction::Replace {
                detail_lines.push(Line::from(""));
                detail_lines.push(Line::from(Span::styled(
                    "Warning: this source would replace an existing local plugin with the same name.",
                    Style::default().fg(Color::Rgb(255, 180, 120)),
                )));
            }
        } else if query.is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "Registry Sources",
                Style::default()
                    .fg(Color::Rgb(255, 236, 175))
                    .add_modifier(Modifier::BOLD),
            )));
            let config = self.load_runtime_config();
            for source in edgecrab_plugins::hub_source_summaries(&config.plugins) {
                detail_lines.push(Line::from(format!(
                    "- {} [{}] — {}",
                    source.label,
                    format!("{:?}", source.trust_level).to_ascii_lowercase(),
                    source.description
                )));
            }
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Use /plugins search --source hermes <query> to constrain the browser to one registry family.",
            ));
        } else if browser.inflight_request_id.is_some() {
            detail_lines.push(Line::from("Searching remote plugin registries…"));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "You can keep typing while results refresh. Source failures are surfaced as notes instead of blocking the browser.",
            ));
        } else {
            detail_lines.push(Line::from("No results for the current query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a broader term, a source name like 'edgecrab' or 'hermes', or use /plugins search --source <name> <query>.",
            ));
        }

        if !browser.notices.is_empty() {
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(Span::styled(
                "Source Notes",
                Style::default()
                    .fg(Color::Rgb(255, 191, 0))
                    .add_modifier(Modifier::BOLD),
            )));
            for notice in &browser.notices {
                detail_lines.push(Line::from(format!("- {notice}")));
            }
        }

        if let Some(action) = &browser.action_in_flight {
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(Span::styled(
                format!("Running: {action}"),
                Style::default().fg(Color::Rgb(210, 240, 175)),
            )));
        }

        if self.detail_fullscreen_active(DetailSurface::RemotePluginBrowser) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &browser.selector.query,
                    header: BrowserChrome {
                        title: &title,
                        placeholder:
                            "Type to search official and configured plugin registries, or use /plugins search --source <name> <query>",
                        icon: "🔌",
                        icon_color: Color::Rgb(255, 210, 110),
                        border_color: if browser.inflight_request_id.is_some() {
                            Color::Rgb(255, 210, 110)
                        } else {
                            Color::Rgb(255, 191, 0)
                        },
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(255, 191, 0),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::RemotePluginBrowser),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(255, 210, 110))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(255, 210, 110))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(255, 210, 110)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(255, 210, 110))),
                        Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(255, 210, 110))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(255, 210, 110))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::RemotePluginBrowser,
            Color::Rgb(255, 191, 0),
            detail_lines,
        );

        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(255, 210, 110)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(255, 210, 110)));
        help_spans.extend([
            Span::styled("Enter ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("default action  ", Style::default().fg(Color::DarkGray)),
            Span::styled("I ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("install/update  ", Style::default().fg(Color::DarkGray)),
            Span::styled("U ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("update  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("refresh  ", Style::default().fg(Color::DarkGray)),
            Span::styled("L ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("local browser  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(255, 210, 110))),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
        ]);
        let status_text = if browser.inflight_request_id.is_some() {
            "searching"
        } else if !query.is_empty() && filtered.is_empty() {
            "no matches"
        } else {
            "matches"
        };
        help_spans.push(Span::styled(
            format!(
                "{} {} · {} pane",
                filtered.len(),
                status_text,
                self.simple_split_focus_label(DetailSurface::RemotePluginBrowser)
            ),
            Style::default().fg(Color::Rgb(155, 140, 105)),
        ));
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn render_tool_manager(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(1),
            ])
            .split(area);
        let body = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
            .split(chunks[1]);

        let tabs = [
            ToolManagerScope::All,
            ToolManagerScope::Toolsets,
            ToolManagerScope::Tools,
        ]
        .into_iter()
        .map(|scope| {
            let style = if scope == self.tool_manager_scope {
                Style::default()
                    .fg(Color::Rgb(255, 238, 170))
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Rgb(120, 132, 146))
            };
            Span::styled(format!("[{}] ", scope.title()), style)
        })
        .collect::<Vec<_>>();

        let search_text = if self.tool_manager.query.is_empty() {
            "Search tools, toolsets, descriptions, or tags".to_string()
        } else {
            self.tool_manager.query.clone()
        };
        let search_style = if self.tool_manager.query.is_empty() {
            Style::default().fg(Color::DarkGray)
        } else {
            Style::default().fg(Color::White)
        };
        let mut search_spans = vec![
            Span::styled("  🧰 ", Style::default().fg(Color::Rgb(140, 220, 210))),
            Span::styled(search_text, search_style),
            Span::raw("   "),
        ];
        search_spans.extend(tabs);
        let search = Paragraph::new(Line::from(search_spans)).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(110, 220, 210)))
                .title(" Tool Manager "),
        );
        frame.render_widget(search, chunks[0]);

        let filtered = &self.tool_manager.filtered;
        let selected = self.tool_manager.selected;
        let max_visible = body[0].height as usize;
        let scroll_start = if selected >= max_visible {
            selected - max_visible + 1
        } else {
            0
        };

        let items: Vec<ListItem> = if filtered.is_empty() {
            vec![ListItem::new(Line::from(Span::styled(
                "  No tools matched the current filter.",
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.tool_manager.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(20, 42, 46)
                    } else {
                        Color::Rgb(16, 22, 28)
                    };
                    let check_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(210, 240, 175))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(145, 185, 120))
                    };
                    let kind_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(150, 180, 188))
                    } else {
                        Style::default().fg(Color::Rgb(95, 115, 125))
                    };
                    let name_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(110, 220, 210))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(210, 220, 220))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(170, 190, 190))
                    } else {
                        Style::default().fg(Color::Rgb(118, 138, 138))
                    };

                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(110, 220, 210), Some(bg)),
                        Span::styled(format!("  {}", entry.check_state.glyph()), check_style),
                        Span::raw("  "),
                        Span::styled(unicode_pad_right(&entry.tag, 8), kind_style),
                        Span::styled(
                            unicode_pad_right(&format!("{} {}", entry.emoji, entry.name), 30),
                            name_style,
                        ),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.detail, 36), detail_style),
                    ]))
                })
                .collect()
        };

        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(16, 22, 28))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.tool_manager.current() {
            let policy_text = match entry.policy_source {
                ToolPolicySource::Default => "inherits default policy",
                ToolPolicySource::ExplicitEnable => "forced on by explicit override",
                ToolPolicySource::ExplicitDisable => "forced off by explicit override",
            };
            let runtime_text = if entry.exposed {
                "visible to the model right now"
            } else if !entry.startup_available {
                "hidden because the tool is unavailable at startup"
            } else if !entry.check_allowed {
                "hidden by runtime gating in this session"
            } else {
                "hidden by current policy"
            };

            detail_lines.push(Line::from(Span::styled(
                format!("{} {}", entry.emoji, entry.name),
                Style::default()
                    .fg(Color::Rgb(110, 220, 210))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(format!("Kind: {}", entry.tag)));
            detail_lines.push(Line::from(format!("Policy: {policy_text}")));
            detail_lines.push(Line::from(format!("Runtime: {runtime_text}")));

            match entry.kind {
                ToolManagerItemKind::Toolset => {
                    detail_lines.push(Line::from(format!(
                        "Coverage: {}/{} selected · {}/{} exposed",
                        entry.selected_tools,
                        entry.total_tools,
                        entry.exposed_tools,
                        entry.total_tools
                    )));
                    if !entry.description.is_empty() {
                        detail_lines.push(Line::from(""));
                        detail_lines.push(Line::from("Included tools:"));
                        for tool in entry.description.split(", ") {
                            detail_lines.push(Line::from(format!("  • {tool}")));
                        }
                    }
                }
                ToolManagerItemKind::Tool => {
                    detail_lines.push(Line::from(format!("Toolset: {}", entry.toolset)));
                    if entry.dynamic {
                        detail_lines.push(Line::from("Origin: dynamic runtime tool"));
                    }
                    if !entry.aliases.is_empty() {
                        detail_lines
                            .push(Line::from(format!("Aliases: {}", entry.aliases.join(", "))));
                    }
                    detail_lines.push(Line::from(""));
                    for line in entry.description.lines() {
                        detail_lines.push(Line::from(line.to_string()));
                    }
                }
            }
        }

        if self.detail_fullscreen_active(DetailSurface::ToolManager) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.tool_manager.query,
                    header: BrowserChrome {
                        title: "Tool Manager",
                        placeholder: "Search tools, toolsets, descriptions, or tags",
                        icon: "🧰",
                        icon_color: Color::Rgb(140, 220, 210),
                        border_color: Color::Rgb(110, 220, 210),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(110, 220, 210),
                        focused: true,
                        requested_scroll: self.detail_fullscreen_scroll(DetailSurface::ToolManager),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(110, 220, 210)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Space ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("toggle  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("←→ ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("scope  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }
        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::ToolManager,
            Color::Rgb(110, 220, 210),
            detail_lines,
        );

        let footer_note = self.tool_manager_status_note.as_deref().unwrap_or(
            "Space toggles. Tab focuses panes. Left/Right changes scope. R restores defaults.",
        );
        let footer_summary = format!(
            "{} · {} scope · {} pane",
            footer_note,
            self.tool_manager_scope.title(),
            self.simple_split_focus_label(DetailSurface::ToolManager)
        );
        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(110, 220, 210)));
        help_spans.extend([
            Span::styled("Space ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("toggle  ", Style::default().fg(Color::DarkGray)),
            Span::styled("←→ ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("scope  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("reset  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 220, 210))),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                edgecrab_core::safe_truncate(&footer_summary, 62),
                Style::default().fg(Color::Rgb(95, 115, 125)),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn render_plugin_toggle(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        let title = format!("Browse Plugins [{}]", self.plugin_toggle_scope.title());
        self.render_browser_header(
            frame,
            chunks[0],
            &self.plugin_toggle.query,
            BrowserChrome {
                title: &title,
                placeholder:
                    "Search installed plugins by name, tool, CLI command, status, source, or trust.",
                icon: "🔌",
                icon_color: Color::Rgb(210, 190, 110),
                border_color: Color::Rgb(210, 190, 110),
            },
        );

        let filtered = &self.plugin_toggle.filtered;
        let selected = self.plugin_toggle.selected;
        let max_visible = body[0].height as usize;
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if self.plugin_toggle.items.is_empty() {
                "  No local plugins installed yet."
            } else if self.plugin_toggle.query.trim().is_empty() {
                "  No plugins available in this scope."
            } else {
                "  No plugins matched the current filter."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.plugin_toggle.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(46, 38, 18)
                    } else {
                        Color::Rgb(22, 22, 18)
                    };
                    let state_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(210, 240, 175))
                    } else {
                        Style::default().fg(Color::Rgb(150, 180, 120))
                    };
                    let pending_style = if entry.has_pending_change() {
                        if is_selected {
                            Style::default().bg(bg).fg(Color::Rgb(255, 195, 120))
                        } else {
                            Style::default().fg(Color::Rgb(210, 150, 90))
                        }
                    } else if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(120, 110, 90))
                    } else {
                        Style::default().fg(Color::Rgb(90, 85, 70))
                    };
                    let kind_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(150, 165, 205))
                    } else {
                        Style::default().fg(Color::Rgb(110, 125, 160))
                    };
                    let name_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(255, 236, 175))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(220, 220, 210))
                    };
                    let trust_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(205, 190, 140))
                    } else {
                        Style::default().fg(Color::Rgb(155, 140, 105))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(160, 160, 145))
                    } else {
                        Style::default().fg(Color::Rgb(120, 120, 110))
                    };
                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(210, 190, 110), Some(bg)),
                        Span::styled(format!("  {:<8}", entry.runtime_status), state_style),
                        Span::raw("  "),
                        Span::styled(
                            format!(
                                "{:<7}",
                                if entry.has_pending_change() {
                                    "staged"
                                } else {
                                    ""
                                }
                            ),
                            pending_style,
                        ),
                        Span::raw("  "),
                        Span::styled(unicode_pad_right(&entry.kind, 8), kind_style),
                        Span::styled(unicode_trunc(&entry.display_name, 24), name_style),
                        Span::raw("  "),
                        Span::styled(unicode_pad_right(&entry.trust_level, 10), trust_style),
                        Span::raw("  "),
                        Span::styled(
                            unicode_trunc(
                                &format!(
                                    "{} {} tool{}",
                                    entry.check_state.glyph(),
                                    entry.tool_count,
                                    if entry.tool_count == 1 { "" } else { "s" }
                                ),
                                18,
                            ),
                            detail_style,
                        ),
                    ]))
                })
                .collect()
        };
        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(22, 22, 18))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.plugin_toggle.current() {
            detail_lines.push(Line::from(Span::styled(
                entry.display_name.clone(),
                Style::default()
                    .fg(Color::Rgb(255, 236, 175))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(vec![
                Span::styled(
                    "Desired state: ",
                    Style::default().fg(Color::Rgb(205, 190, 140)),
                ),
                Span::raw(entry.state_label()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled(
                    "Runtime status: ",
                    Style::default().fg(Color::Rgb(205, 190, 140)),
                ),
                Span::raw(entry.runtime_status.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Scope: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(self.plugin_toggle_scope.title()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Kind: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(entry.kind.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Trust: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(entry.trust_level.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Version: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(entry.version.clone()),
            ]));
            detail_lines.push(Line::from(vec![
                Span::styled("Source: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(entry.source.clone()),
            ]));
            if let Some(install_source) = entry.install_source.as_deref() {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Install source: ",
                        Style::default().fg(Color::Rgb(205, 190, 140)),
                    ),
                    Span::raw(install_source.to_string()),
                ]));
            }
            detail_lines.push(Line::from(vec![
                Span::styled("Tools: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                Span::raw(if entry.tools.is_empty() {
                    "none".into()
                } else {
                    entry.tools.join(", ")
                }),
            ]));
            if !entry.cli_commands.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled("CLI: ", Style::default().fg(Color::Rgb(205, 190, 140))),
                    Span::raw(entry.cli_commands.join(", ")),
                ]));
            }
            if let Some(compatibility) = entry.compatibility.as_deref() {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Compatibility: ",
                        Style::default().fg(Color::Rgb(205, 190, 140)),
                    ),
                    Span::raw(compatibility.to_string()),
                ]));
            }
            if !entry.related_skills.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Related skills: ",
                        Style::default().fg(Color::Rgb(205, 190, 140)),
                    ),
                    Span::raw(entry.related_skills.join(", ")),
                ]));
            }
            detail_lines.push(Line::from(vec![
                Span::styled(
                    "Estimated prompt cost: ",
                    Style::default().fg(Color::Rgb(205, 190, 140)),
                ),
                Span::raw(format!("~{} tokens", entry.estimated_tokens)),
            ]));
            if !entry.missing_env.is_empty() {
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        "Missing env: ",
                        Style::default().fg(Color::Rgb(255, 180, 120)),
                    ),
                    Span::raw(entry.missing_env.join(", ")),
                ]));
            }
            if entry.has_pending_change() {
                detail_lines.push(Line::from(""));
                detail_lines.push(Line::from(Span::styled(
                    "This plugin has staged changes that are not saved yet.",
                    Style::default().fg(Color::Rgb(255, 195, 120)),
                )));
            }
            detail_lines.push(Line::from(""));
            for line in entry.description.lines() {
                detail_lines.push(Line::from(line.to_string()));
            }
        } else if self.plugin_toggle.items.is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "No Local Plugins",
                Style::default()
                    .fg(Color::Rgb(255, 236, 175))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Install a local Hermes plugin with `edgecrab plugins install ./path`, or press R to search remote plugin registries.",
            ));
        } else if self.plugin_toggle.query.trim().is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "Installed Plugins",
                Style::default()
                    .fg(Color::Rgb(255, 236, 175))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Browse installed plugins by status, tool surface, trust, and source.",
            ));
            detail_lines.push(Line::from(
                "Space stages enable or disable changes. Enter saves them for the active scope.",
            ));
            detail_lines.push(Line::from(
                "Tab switches between the list and detail panes. Use Left and Right to change the active policy scope.",
            ));
        } else {
            detail_lines.push(Line::from("No plugins matched the current query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a broader term, a tool name, a trust level, or press R to search remote sources.",
            ));
        }

        if self.detail_fullscreen_active(DetailSurface::PluginToggle) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.plugin_toggle.query,
                    header: BrowserChrome {
                        title: &title,
                        placeholder:
                            "Search installed plugins by name, tool, CLI command, status, source, or trust.",
                        icon: "🔌",
                        icon_color: Color::Rgb(210, 190, 110),
                        border_color: Color::Rgb(210, 190, 110),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(210, 190, 110),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::PluginToggle),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(210, 190, 110)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Space ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("stage change  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("save  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("←→ ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("scope  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("R ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("remote search  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(210, 190, 110))),
                        Span::styled("close", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }
        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::PluginToggle,
            Color::Rgb(210, 190, 110),
            detail_lines,
        );

        let footer_note = self
            .plugin_toggle_status_note
            .clone()
            .unwrap_or_else(|| plugin_toggle_status_line(&self.plugin_toggle.items));
        let footer_summary = format!(
            "{} · {} scope · {} pane",
            footer_note,
            self.plugin_toggle_scope.title(),
            self.simple_split_focus_label(DetailSurface::PluginToggle)
        );
        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(210, 190, 110)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(210, 190, 110)));
        help_spans.extend([
            Span::styled("Space ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("stage change  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("save  ", Style::default().fg(Color::DarkGray)),
            Span::styled("←→ ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("scope  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("remote search  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(210, 190, 110))),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                edgecrab_core::safe_truncate(&footer_summary, 62),
                Style::default().fg(Color::Rgb(120, 120, 110)),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn build_config_detail_lines(&self, entry: Option<&ConfigEntry>) -> Vec<Line<'static>> {
        let mut detail_lines = Vec::new();
        if let Some(entry) = entry {
            detail_lines.push(Line::from(Span::styled(
                entry.title.clone(),
                Style::default()
                    .fg(Color::Rgb(130, 210, 255))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(entry.detail.clone()));
            detail_lines.push(Line::from(""));
            let detail_body = match entry.action {
                ConfigAction::ShowSummary => self.render_config_summary(),
                ConfigAction::ShowPaths => self.render_config_paths(),
                ConfigAction::ShowWorktree => self.render_worktree_report(),
                ConfigAction::OpenTools => {
                    "Press Enter to open the live tool manager. Use Tab to move between the list and detail panes, Left and Right to change scope, Space to toggle toolsets or individual tools, and R to restore defaults.".into()
                }
                ConfigAction::OpenGatewayBrowser => {
                    "Press Enter to open the gateway control browser. From there you can toggle platforms, edit bind settings, change allowlists, update home channels, and restart the gateway runtime without leaving the TUI.".into()
                }
                ConfigAction::ShowGatewayHomes => {
                    let config = self.load_runtime_config();
                    self.render_gateway_home_channel_summary(&config)
                }
                ConfigAction::ShowVoice => format!(
                    "Voice readback is {}.\nRun `/voice status` for recorder, provider, and push-to-talk details.",
                    if self.voice_mode_enabled {
                        "enabled"
                    } else {
                        "disabled"
                    }
                ),
                ConfigAction::ShowUpdateStatus => {
                    "Runs the local git-based update check and prints ahead/behind guidance.".into()
                }
                ConfigAction::OpenModel => "Press Enter to open the model selector overlay.".into(),
                ConfigAction::OpenCheapModel => {
                    "Press Enter to open the cheap-model selector. Selecting a model enables smart routing for obviously simple turns.".into()
                }
                ConfigAction::ToggleMoa => {
                    "Press Enter to enable or disable the moa tool while keeping the saved aggregator and expert roster.".into()
                }
                ConfigAction::OpenVisionModel => {
                    "Press Enter to open the dedicated vision-model selector.".into()
                }
                ConfigAction::OpenImageModel => {
                    "Press Enter to open the image-model selector.".into()
                }
                ConfigAction::OpenMoaAggregator => {
                    "Press Enter to pick the default aggregator model used by the moa tool.".into()
                }
                ConfigAction::OpenMoaReferences => {
                    "Press Enter to edit the full default MoA expert roster. Use Space to toggle experts and Enter to save.".into()
                }
                ConfigAction::AddMoaExpert => {
                    "Press Enter to choose one model to add to the saved MoA expert roster.".into()
                }
                ConfigAction::RemoveMoaExpert => {
                    "Press Enter to choose one configured expert to remove from the saved MoA roster.".into()
                }
                ConfigAction::ToggleStreaming => {
                    "Press Enter to toggle live token streaming.".into()
                }
                ConfigAction::ToggleReasoning => {
                    "Press Enter to toggle visible reasoning output.".into()
                }
                ConfigAction::ToggleStatusBar => {
                    "Press Enter to show or hide the status bar.".into()
                }
                ConfigAction::OpenLogs => {
                    "Press Enter to open the local log browser. Inside it you can inspect file tails, drill into individual entries, and save the default log level for future launches.".into()
                }
                ConfigAction::OpenSkins => {
                    "Press Enter to browse installed skins and apply one live.".into()
                }
            };
            for line in detail_body.lines() {
                detail_lines.push(Line::from(line.to_string()));
            }
        }
        detail_lines
    }

    fn render_config_selector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(1),
            ])
            .split(area);
        let body = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
            .split(chunks[1]);

        let search_text = if self.config_selector.query.is_empty() {
            "Type to filter settings and controls…".to_string()
        } else {
            self.config_selector.query.clone()
        };
        let search_style = if self.config_selector.query.is_empty() {
            Style::default().fg(Color::DarkGray)
        } else {
            Style::default().fg(Color::White)
        };
        let search = Paragraph::new(Line::from(vec![
            Span::styled("  ⚙ ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled(search_text, search_style),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(130, 210, 255)))
                .title(" Config Center  [/config] "),
        );
        frame.render_widget(search, chunks[0]);

        let filtered = &self.config_selector.filtered;
        let selected = self.config_selector.selected;
        let max_visible = body[0].height as usize;
        let scroll_start = if selected >= max_visible {
            selected - max_visible + 1
        } else {
            0
        };

        let items: Vec<ListItem> = if filtered.is_empty() {
            vec![ListItem::new(Line::from(Span::styled(
                "  No settings matched the current filter.",
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.config_selector.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(22, 36, 44)
                    } else {
                        Color::Rgb(18, 22, 28)
                    };
                    let tag_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(150, 180, 200))
                    } else {
                        Style::default().fg(Color::Rgb(105, 125, 140))
                    };
                    let title_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(130, 210, 255))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(220, 232, 240))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(172, 190, 204))
                    } else {
                        Style::default().fg(Color::Rgb(125, 140, 150))
                    };
                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(130, 210, 255), Some(bg)),
                        Span::styled(format!("  {:<9}", entry.tag), tag_style),
                        Span::styled(unicode_pad_right(&entry.title, 28), title_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.detail, 54), detail_style),
                    ]))
                })
                .collect()
        };
        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(18, 22, 28))),
            body[0],
        );

        let detail_lines = self.build_config_detail_lines(self.config_selector.current());
        if self.detail_fullscreen_active(DetailSurface::ConfigSelector) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.config_selector.query,
                    header: BrowserChrome {
                        title: "Config Center",
                        placeholder: "Type to filter settings and controls…",
                        icon: "⚙",
                        icon_color: Color::Rgb(130, 210, 255),
                        border_color: Color::Rgb(130, 210, 255),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(130, 210, 255),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::ConfigSelector),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(130, 210, 255))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(130, 210, 255))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(130, 210, 255)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(130, 210, 255))),
                        Span::styled("run action  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(130, 210, 255))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(130, 210, 255))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }
        self.render_standard_split_detail(
            frame,
            body[1],
            DetailSurface::ConfigSelector,
            Color::Rgb(130, 210, 255),
            detail_lines,
        );

        let mut help_spans = vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
        ];
        help_spans.extend(self.focus_pane_help_spans(Color::Rgb(130, 210, 255)));
        help_spans.extend(self.page_or_scroll_help_spans(Color::Rgb(130, 210, 255)));
        help_spans.extend([
            Span::styled("Enter ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled("run action  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled("cancel  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} item(s) · {} pane",
                    filtered.len(),
                    self.simple_split_focus_label(DetailSurface::ConfigSelector)
                ),
                Style::default().fg(Color::Rgb(100, 120, 130)),
            ),
        ]);
        let help = Paragraph::new(Line::from(help_spans));
        frame.render_widget(help, chunks[2]);
    }

    fn render_gateway_browser(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.gateway_browser.query,
            BrowserChrome {
                title: "Gateway Control",
                placeholder:
                    "Search platforms by name, state, delivery mode, or missing setup fields.",
                icon: "⛵",
                icon_color: Color::Rgb(120, 220, 200),
                border_color: Color::Rgb(120, 220, 200),
            },
        );

        let filtered = &self.gateway_browser.filtered;
        let selected = self.gateway_browser.selected;
        let max_visible = Self::browser_list_visible_rows(body[0], false);
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            vec![ListItem::new(Line::from(Span::styled(
                "  No gateway platforms matched this filter.".to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.gateway_browser.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(18, 42, 42)
                    } else {
                        Color::Rgb(18, 22, 28)
                    };
                    let accent = match entry.diagnostic.state {
                        crate::gateway_catalog::PlatformState::Ready => Color::Rgb(120, 220, 160),
                        crate::gateway_catalog::PlatformState::Available => {
                            Color::Rgb(170, 210, 120)
                        }
                        crate::gateway_catalog::PlatformState::Incomplete => {
                            Color::Rgb(255, 180, 110)
                        }
                        crate::gateway_catalog::PlatformState::NotConfigured => {
                            Color::Rgb(120, 140, 150)
                        }
                    };
                    let tag_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(155, 185, 175))
                    } else {
                        Style::default().fg(Color::Rgb(105, 125, 118))
                    };
                    let title_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(accent)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(accent)
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(175, 195, 188))
                    } else {
                        Style::default().fg(Color::Rgb(125, 140, 150))
                    };

                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, accent, Some(bg)),
                        Span::styled(
                            format!("  {:<16}", entry.diagnostic.state.label()),
                            tag_style,
                        ),
                        Span::styled(unicode_pad_right(entry.diagnostic.name, 12), title_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.summary, 64), detail_style),
                    ]))
                })
                .collect()
        };
        frame.render_widget(
            List::new(items).style(Style::default().bg(Color::Rgb(18, 22, 28))),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.gateway_browser.current() {
            for line in entry.detail_view.lines() {
                detail_lines.push(Line::from(line.to_string()));
            }
        } else {
            detail_lines.push(Line::from(Span::styled(
                "Gateway Control",
                Style::default()
                    .fg(Color::Rgb(120, 220, 200))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "This browser turns `/platforms` into an operator cockpit instead of a text dump.",
            ));
            detail_lines.push(Line::from(
                "Use Enter for the next setup field, Space to toggle enablement, and B to edit the gateway bind address without leaving the TUI.",
            ));
        }

        if self.detail_fullscreen_active(DetailSurface::GatewayBrowser) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.gateway_browser.query,
                    header: BrowserChrome {
                        title: "Gateway Control",
                        placeholder:
                            "Search platforms by name, state, delivery mode, or missing setup fields.",
                        icon: "⛵",
                        icon_color: Color::Rgb(120, 220, 200),
                        border_color: Color::Rgb(120, 220, 200),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(120, 220, 200),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::GatewayBrowser),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(120, 220, 200)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("edit setup  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Space ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("toggle  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("X ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("restart  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(120, 220, 200))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        self.render_scrollable_browser_detail(
            frame,
            body[1],
            ScrollableDetailChrome {
                title: "Details",
                border_color: Color::Rgb(120, 220, 200),
                focused: self.gateway_browser_pane.focus == SplitPaneFocus::Detail,
                requested_scroll: self.gateway_browser_pane.scroll,
            },
            detail_lines,
        );

        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("browse  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("edit key field  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Space ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("toggle  ", Style::default().fg(Color::DarkGray)),
            Span::styled("A ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("allowlist  ", Style::default().fg(Color::DarkGray)),
            Span::styled("H ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("home  ", Style::default().fg(Color::DarkGray)),
            Span::styled("B ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("bind  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("refresh  ", Style::default().fg(Color::DarkGray)),
            Span::styled("X ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("restart  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(120, 220, 200))),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!("{} platform(s)", filtered.len()),
                Style::default().fg(Color::Rgb(95, 120, 112)),
            ),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn render_log_browser(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let accent = Color::Rgb(255, 196, 120);
        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.log_browser.query,
            BrowserChrome {
                title: "Log Browser",
                placeholder: "Search log files by name, type, preview text, or size.",
                icon: "◷",
                icon_color: accent,
                border_color: accent,
            },
        );

        let filtered = &self.log_browser.filtered;
        let selected = self.log_browser.selected;
        let max_visible = Self::browser_list_visible_rows(body[0], true);
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            vec![ListItem::new(Line::from(Span::styled(
                "  No log files matched the current filter.",
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.log_browser.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(46, 34, 20)
                    } else {
                        Color::Rgb(18, 22, 28)
                    };
                    let tag_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(230, 188, 140))
                    } else {
                        Style::default().fg(Color::Rgb(150, 126, 100))
                    };
                    let title_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(accent)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(232, 226, 214))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(208, 182, 150))
                    } else {
                        Style::default().fg(Color::Rgb(140, 136, 128))
                    };
                    let preview_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(220, 204, 184))
                    } else {
                        Style::default().fg(Color::Rgb(150, 150, 150))
                    };

                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, accent, Some(bg)),
                        Span::styled(format!("  {:<8}", entry.kind), tag_style),
                        Span::styled(unicode_trunc(&entry.name, 24), title_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.detail, 30), detail_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.preview, 28), preview_style),
                    ]))
                })
                .collect()
        };
        let list_border_style = if self.log_browser_pane.focus == SplitPaneFocus::List {
            Style::default().fg(accent).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Rgb(60, 80, 84))
        };
        frame.render_widget(
            List::new(items)
                .style(Style::default().bg(Color::Rgb(18, 22, 28)))
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(list_border_style)
                        .title(" Files "),
                ),
            body[0],
        );

        let detail_lines = if let Some(entry) = self.log_browser.current() {
            let mut lines = vec![Line::from(vec![
                Span::styled(
                    format!("{} ", entry.kind),
                    Style::default().fg(accent).add_modifier(Modifier::BOLD),
                ),
                Span::raw(entry.name.clone()),
            ])];
            lines.push(Line::from(""));
            lines.extend(
                entry
                    .detail_view
                    .lines()
                    .map(|line| Line::from(line.to_string())),
            );
            lines
        } else {
            default_log_browser_detail_lines()
        };

        self.render_scrollable_browser_detail(
            frame,
            body[1],
            ScrollableDetailChrome {
                title: "Details",
                border_color: accent,
                focused: self.log_browser_pane.focus == SplitPaneFocus::Detail,
                requested_scroll: self.log_browser_pane.scroll,
            },
            detail_lines.clone(),
        );
        if self.detail_fullscreen_active(DetailSurface::LogBrowser) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.log_browser.query,
                    header: BrowserChrome {
                        title: "Log Browser",
                        placeholder: "Search log files by name, type, preview text, or size.",
                        icon: "◷",
                        icon_color: accent,
                        border_color: accent,
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: accent,
                        focused: true,
                        requested_scroll: self.detail_fullscreen_scroll(DetailSurface::LogBrowser),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(accent)),
                        Span::styled("change file  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(accent)),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(accent),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(accent)),
                        Span::styled("inspect  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("F ", Style::default().fg(accent)),
                        Span::styled("follow  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("1-5 ", Style::default().fg(accent)),
                        Span::styled("level  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(accent)),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(accent)),
                        Span::styled("close", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        let note = self.log_browser_status_note.as_deref().unwrap_or(
            "Lowercase refines the filter. Enter opens an entry inspector for the selected file tail.",
        );
        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(accent)),
            Span::styled("files  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Tab ", Style::default().fg(accent)),
            Span::styled("focus pane  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(accent)),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
            self.paging_key_help_span(accent),
            Span::styled("page or scroll  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(accent)),
            Span::styled("inspect  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(accent)),
            Span::styled("reload  ", Style::default().fg(Color::DarkGray)),
            Span::styled("F ", Style::default().fg(accent)),
            Span::styled("follow  ", Style::default().fg(Color::DarkGray)),
            Span::styled("1-5 ", Style::default().fg(accent)),
            Span::styled("level  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(accent)),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(accent)),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} file(s) · {} pane · {}",
                    filtered.len(),
                    match self.log_browser_pane.focus {
                        SplitPaneFocus::List => "list",
                        SplitPaneFocus::Detail => "detail",
                    },
                    note
                ),
                Style::default().fg(Color::Rgb(130, 120, 105)),
            ),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn build_log_inspector_detail_lines(&self) -> Vec<Line<'static>> {
        let mut detail_lines = Vec::new();
        if let Some(file) = self.log_inspector.file.as_ref() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    file.name.clone(),
                    Style::default()
                        .fg(Color::Rgb(255, 196, 120))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!("  ({})", file.kind)),
            ]));
            detail_lines.push(Line::from(format!(
                "{} · modified {}",
                file.size_label, file.modified_label
            )));
            detail_lines.push(Line::from(format!("Path: {}", file.path.display())));
            detail_lines.push(Line::from(format!(
                "Live follow: {}",
                self.log_follow.badge()
            )));
            if !file.preview.is_empty() {
                detail_lines.push(Line::from(format!("Tail preview: {}", file.preview)));
            }
            if !self.log_inspector.selector.query.trim().is_empty() {
                detail_lines.push(Line::from(format!(
                    "Local filter: {}",
                    self.log_inspector.selector.query
                )));
            }
            detail_lines.push(Line::from(""));

            if let Some(entry) = self.log_inspector.selector.current() {
                detail_lines.push(Line::from(Span::styled(
                    "Selected entry",
                    Style::default().add_modifier(Modifier::BOLD),
                )));
                detail_lines.push(Line::from(format!(
                    "{} · {}",
                    entry.level_label, entry.timestamp
                )));
                detail_lines.push(Line::from(format!("Summary: {}", entry.summary)));
                detail_lines.push(Line::from(""));
                for line in entry.detail.lines() {
                    detail_lines.push(Line::from(line.to_string()));
                }
            } else {
                detail_lines.push(Line::from(
                    "No log entry is selected. Clear the filter or move the cursor to inspect the tail.",
                ));
            }
        }
        detail_lines
    }

    fn render_log_inspector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let accent = Color::Rgb(255, 196, 120);
        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.log_inspector.selector.query,
            BrowserChrome {
                title: "Log Inspector",
                placeholder: "Filter the selected file tail by level, timestamp, message, or stacktrace text.",
                icon: "⌘",
                icon_color: accent,
                border_color: accent,
            },
        );

        let filtered = &self.log_inspector.selector.filtered;
        let selected = self.log_inspector.selector.selected;
        let max_visible = Self::browser_list_visible_rows(body[0], true);
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            vec![ListItem::new(Line::from(Span::styled(
                "  No log entries matched the current filter.",
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.log_inspector.selector.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(46, 34, 20)
                    } else {
                        Color::Rgb(18, 22, 28)
                    };
                    let tag_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(255, 210, 150))
                    } else {
                        Style::default().fg(Color::Rgb(170, 140, 112))
                    };
                    let title_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(accent)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(225, 220, 210))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(215, 194, 168))
                    } else {
                        Style::default().fg(Color::Rgb(145, 145, 145))
                    };

                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, accent, Some(bg)),
                        Span::styled(format!("  {:<6}", entry.level_label), tag_style),
                        Span::styled(unicode_trunc(&entry.timestamp, 19), detail_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.summary, 72), title_style),
                    ]))
                })
                .collect()
        };
        let list_border_style = if self.log_inspector.pane.focus == SplitPaneFocus::List {
            Style::default().fg(accent).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Rgb(60, 80, 84))
        };
        frame.render_widget(
            List::new(items)
                .style(Style::default().bg(Color::Rgb(18, 22, 28)))
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(list_border_style)
                        .title(" Entries "),
                ),
            body[0],
        );

        let detail_lines = self.build_log_inspector_detail_lines();
        self.render_scrollable_browser_detail(
            frame,
            body[1],
            ScrollableDetailChrome {
                title: "Details",
                border_color: accent,
                focused: self.log_inspector.pane.focus == SplitPaneFocus::Detail,
                requested_scroll: self.log_inspector.pane.scroll,
            },
            detail_lines.clone(),
        );
        if self.detail_fullscreen_active(DetailSurface::LogInspector) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.log_inspector.selector.query,
                    header: BrowserChrome {
                        title: "Log Inspector",
                        placeholder: "Filter the selected file tail by level, timestamp, message, or stacktrace text.",
                        icon: "⌘",
                        icon_color: accent,
                        border_color: accent,
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: accent,
                        focused: true,
                        requested_scroll: self.detail_fullscreen_scroll(DetailSurface::LogInspector),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(accent)),
                        Span::styled("change entry  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(accent)),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(accent),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("B ", Style::default().fg(accent)),
                        Span::styled("back  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("R ", Style::default().fg(accent)),
                        Span::styled("reload  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("F ", Style::default().fg(accent)),
                        Span::styled("follow  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("1-5 ", Style::default().fg(accent)),
                        Span::styled("level  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(accent)),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(accent)),
                        Span::styled("close", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(accent)),
            Span::styled("entries  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Tab ", Style::default().fg(accent)),
            Span::styled("focus pane  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(accent)),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
            self.paging_key_help_span(accent),
            Span::styled("page or scroll  ", Style::default().fg(Color::DarkGray)),
            Span::styled("B ", Style::default().fg(accent)),
            Span::styled("back  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(accent)),
            Span::styled("reload  ", Style::default().fg(Color::DarkGray)),
            Span::styled("F ", Style::default().fg(accent)),
            Span::styled("follow  ", Style::default().fg(Color::DarkGray)),
            Span::styled("1-5 ", Style::default().fg(accent)),
            Span::styled("level  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(accent)),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(accent)),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} entry(s) · {} pane · {}",
                    filtered.len(),
                    match self.log_inspector.pane.focus {
                        SplitPaneFocus::List => "list",
                        SplitPaneFocus::Detail => "detail",
                    },
                    self.log_follow.badge()
                ),
                Style::default().fg(Color::Rgb(130, 120, 105)),
            ),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // ── Gateway Diagnostics Overlay ──────────────────────────────────────────
    // ─────────────────────────────────────────────────────────────────────────

    /// Render the full-screen Gateway Diagnostics overlay.
    ///
    /// DRY: re-uses `browser_overlay_chunks` and the established accent palette
    /// so it inherits the same visual language as other overlays with zero
    /// extra styling primitives.
    fn render_diagnose_panel(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        // Accent: teal — distinct from the warm-amber log/model overlays.
        let accent = Color::Rgb(80, 220, 200);
        let heading_color = Color::Rgb(255, 220, 100);
        let ok_color = Color::Rgb(100, 220, 100);
        let err_color = Color::Rgb(255, 100, 80);
        let warn_color = Color::Rgb(255, 180, 50);
        let dim_color = Color::Rgb(130, 130, 140);
        let bg = Color::Rgb(14, 20, 26);

        // ── Layout ───────────────────────────────────────────────────────────
        // 3 rows: header bar (3) / body / footer (1)
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(1),
            ])
            .split(area);

        // ── Header ───────────────────────────────────────────────────────────
        let title_text = if self.diagnose_panel.refresh_in_flight {
            "  Gateway Diagnostics  ·  refreshing…"
        } else {
            "  Gateway Diagnostics  ·  /gateway diagnose"
        };
        let header = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(accent))
            .title(Span::styled(
                title_text,
                Style::default().fg(accent).add_modifier(Modifier::BOLD),
            ))
            .style(Style::default().bg(bg));
        frame.render_widget(header, chunks[0]);

        // ── Body: colorized report lines ─────────────────────────────────────
        let report_lines: Vec<Line> = self
            .diagnose_panel
            .report
            .lines()
            .map(|raw| {
                Self::colorize_diagnose_line(
                    raw,
                    ok_color,
                    err_color,
                    warn_color,
                    heading_color,
                    dim_color,
                    accent,
                )
            })
            .collect();

        let visible_height = chunks[1].height.saturating_sub(2) as usize;
        let scroll = self
            .diagnose_panel
            .scroll
            .min(self.diagnose_panel.total_lines.saturating_sub(1));

        let paragraph = Paragraph::new(report_lines)
            .scroll((scroll as u16, 0))
            .style(Style::default().bg(bg).fg(Color::Rgb(225, 220, 210)))
            .block(
                Block::default()
                    .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
                    .border_style(Style::default().fg(Color::Rgb(50, 70, 80)))
                    .style(Style::default().bg(bg)),
            )
            .wrap(ratatui::widgets::Wrap { trim: false });
        frame.render_widget(paragraph, chunks[1]);

        // Scroll position indicator (top-right of body border)
        let total = self.diagnose_panel.total_lines;
        let bottom = (scroll + visible_height).min(total);
        let pos_text = format!(" {scroll}–{bottom}/{total} ");
        let pos_x = chunks[1]
            .x
            .saturating_add(chunks[1].width.saturating_sub(pos_text.len() as u16 + 1));
        let pos_area = Rect {
            x: pos_x,
            y: chunks[1].y + chunks[1].height.saturating_sub(1),
            width: pos_text.len() as u16,
            height: 1,
        };
        frame.render_widget(
            Paragraph::new(Span::styled(pos_text, Style::default().fg(dim_color))),
            pos_area,
        );

        // ── Footer: key hints ────────────────────────────────────────────────
        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(accent)),
            Span::styled("scroll  ", Style::default().fg(dim_color)),
            Span::styled("PgUp/PgDn ", Style::default().fg(accent)),
            Span::styled("page  ", Style::default().fg(dim_color)),
            Span::styled("R ", Style::default().fg(accent)),
            Span::styled("refresh  ", Style::default().fg(dim_color)),
            Span::styled("Esc/Q ", Style::default().fg(accent)),
            Span::styled("close", Style::default().fg(dim_color)),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    /// Handle keyboard input while the Gateway Diagnostics overlay is open.
    fn handle_diagnose_panel_key(&mut self, key: event::KeyEvent) {
        let page = self.output_area_height.max(4).saturating_sub(2) as usize;
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
                self.diagnose_panel.active = false;
            }
            KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => {
                self.diagnose_panel.scroll = self.diagnose_panel.scroll.saturating_sub(1);
            }
            KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => {
                let max = self.diagnose_panel.total_lines.saturating_sub(1);
                self.diagnose_panel.scroll = (self.diagnose_panel.scroll + 1).min(max);
            }
            KeyCode::PageUp => {
                self.diagnose_panel.scroll = self.diagnose_panel.scroll.saturating_sub(page);
            }
            KeyCode::PageDown => {
                let max = self.diagnose_panel.total_lines.saturating_sub(1);
                self.diagnose_panel.scroll = (self.diagnose_panel.scroll + page).min(max);
            }
            KeyCode::Home => {
                self.diagnose_panel.scroll = 0;
            }
            KeyCode::End => {
                self.diagnose_panel.scroll = self.diagnose_panel.total_lines.saturating_sub(1);
            }
            KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::F(5) => {
                // Refresh: re-run diagnostics and stay in overlay.
                self.handle_gateway_control("diagnose".into());
            }
            _ => {}
        }
        self.needs_redraw = true;
    }

    /// Apply semantic TUI colors to a single line from the gateway diagnose report.
    ///
    /// WHY free associated function (no `self`): `render_diagnose_panel` maps over
    /// report lines and only needs the raw text + color palette — no app state.
    /// Keeping it here keeps all diagnose-overlay logic co-located.
    fn colorize_diagnose_line<'a>(
        raw: &'a str,
        ok_color: Color,
        err_color: Color,
        warn_color: Color,
        heading_color: Color,
        dim_color: Color,
        accent: Color,
    ) -> Line<'a> {
        // Box-drawing borders (╔ ╚ ║ ═ etc.)
        if raw.contains('╔') || raw.contains('╚') || raw.contains('║') || raw.contains('╠')
        {
            return Line::from(Span::styled(
                raw,
                Style::default().fg(accent).add_modifier(Modifier::BOLD),
            ));
        }
        // Section dividers e.g. "── Title ──"
        let trimmed = raw.trim_start();
        if trimmed.starts_with("──") || trimmed.starts_with("─────") {
            return Line::from(Span::styled(raw, Style::default().fg(heading_color)));
        }
        // Status marker lines
        if raw.contains(" \u{2713} ") || raw.contains(" \u{2714} ") {
            // ✓ ✔
            return Line::from(Span::styled(raw, Style::default().fg(ok_color)));
        }
        if raw.contains(" \u{2717} ") || raw.contains(" \u{2718} ") {
            // ✗ ✘
            return Line::from(Span::styled(raw, Style::default().fg(err_color)));
        }
        if raw.contains(" \u{25cb} ") || raw.contains(" \u{25e6} ") {
            // ○ ◦  (offline / not configured)
            return Line::from(Span::styled(raw, Style::default().fg(dim_color)));
        }
        // Issues section entries  e.g. "[ERROR] ..."
        if trimmed.starts_with('[') && (trimmed.contains("] ") || trimmed.ends_with(']')) {
            let color = if trimmed.contains("[WARN") {
                warn_color
            } else {
                err_color
            };
            return Line::from(Span::styled(raw, Style::default().fg(color)));
        }
        // Fix suggestions
        if trimmed.starts_with("Fix:") || trimmed.starts_with("fix:") {
            return Line::from(Span::styled(
                raw,
                Style::default().fg(ok_color).add_modifier(Modifier::BOLD),
            ));
        }
        // Log severity keywords
        if raw.contains("ERROR") {
            return Line::from(Span::styled(raw, Style::default().fg(err_color)));
        }
        if raw.contains("WARN") {
            return Line::from(Span::styled(raw, Style::default().fg(warn_color)));
        }
        // Quick-action command lines e.g. "  edgecrab gateway start"
        if trimmed.starts_with("edgecrab ") {
            return Line::from(Span::styled(raw, Style::default().fg(accent)));
        }
        // Default: plain text, let the paragraph style apply
        Line::from(raw)
    }

    fn build_session_inspector_detail_lines(&self) -> Vec<Line<'static>> {
        let mut detail_lines = Vec::new();
        let selector = &self.session_inspector.selector;

        if let Some(session) = self.session_inspector.session.as_ref() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    session.title.clone(),
                    Style::default()
                        .fg(Color::Rgb(120, 215, 185))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!("  ({})", session.source)),
            ]));
            detail_lines.push(Line::from(format!(
                "{} · {} messages · started {} · last active {}",
                session.model,
                session.message_count,
                session.started_label,
                session.last_active_label
            )));
            detail_lines.push(Line::from(format!("Session ID: {}", session.id)));
            if !session.preview.is_empty() {
                detail_lines.push(Line::from(format!("Preview: {}", session.preview)));
            }
            if let (Some(role), Some(snippet)) = (
                session.matched_role.as_deref(),
                session.matched_snippet.as_deref(),
            ) {
                detail_lines.push(Line::from(format!(
                    "Opened from browser match: {role} -> {snippet}"
                )));
            }
            if !selector.query.trim().is_empty() {
                detail_lines.push(Line::from(format!("Local filter: {}", selector.query)));
            }
            if session.is_live {
                detail_lines.push(Line::from("Mode: live in-memory session debugger"));
            }
            for line in &session.debug_lines {
                detail_lines.push(Line::from(line.clone()));
            }
            detail_lines.push(Line::from(""));

            if let Some(entry) = selector.current() {
                detail_lines.push(Line::from(Span::styled(
                    "Selected message",
                    Style::default().add_modifier(Modifier::BOLD),
                )));
                detail_lines.push(Line::from(vec![
                    Span::styled(
                        entry.headline.clone(),
                        Style::default()
                            .fg(Color::Rgb(120, 215, 185))
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::raw(format!("  {}", entry.meta)),
                ]));
                detail_lines.push(Line::from(""));

                let content = entry.message.text_content();
                if !content.trim().is_empty() {
                    detail_lines.push(Line::from(Span::styled(
                        "Content",
                        Style::default().add_modifier(Modifier::BOLD),
                    )));
                    for line in content.lines() {
                        detail_lines.push(Line::from(line.to_string()));
                    }
                } else {
                    detail_lines.push(Line::from("(No text content stored for this message.)"));
                }

                if let Some(reasoning) = entry
                    .message
                    .reasoning
                    .as_deref()
                    .filter(|reasoning| !reasoning.trim().is_empty())
                {
                    detail_lines.push(Line::from(""));
                    detail_lines.push(Line::from(Span::styled(
                        "Reasoning",
                        Style::default().add_modifier(Modifier::BOLD),
                    )));
                    for line in reasoning.lines() {
                        detail_lines.push(Line::from(line.to_string()));
                    }
                }

                if let Some(tool_calls) = entry
                    .message
                    .tool_calls
                    .as_ref()
                    .filter(|tool_calls| !tool_calls.is_empty())
                {
                    detail_lines.push(Line::from(""));
                    detail_lines.push(Line::from(Span::styled(
                        "Tool Calls",
                        Style::default().add_modifier(Modifier::BOLD),
                    )));
                    for call in tool_calls {
                        detail_lines.push(Line::from(format!(
                            "- {}  ({})",
                            call.function.name, call.id
                        )));
                    }
                }
            } else {
                detail_lines.push(Line::from(
                    "No message is selected. Clear the filter or move the cursor to inspect the timeline.",
                ));
            }
        }

        detail_lines
    }

    /// Render the session browser overlay (activated by F5 or `/sessions`).
    fn render_session_browser(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.session_browser.query,
            BrowserChrome {
                title: "Session Browser",
                placeholder: "Search by title, id, source, model, or any indexed message text.",
                icon: "⏱",
                icon_color: Color::Rgb(110, 190, 255),
                border_color: Color::Rgb(110, 190, 255),
            },
        );

        let filtered = &self.session_browser.filtered;
        let selected = self.session_browser.selected;
        let max_visible = Self::browser_list_visible_rows(body[0], true);
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if self.session_browser.query.trim().is_empty() {
                "  No saved sessions are available."
            } else {
                "  No sessions matched this query."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &self.session_browser.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(20, 34, 48)
                    } else {
                        Color::Rgb(18, 22, 28)
                    };
                    let source_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(140, 170, 210))
                    } else {
                        Style::default().fg(Color::Rgb(95, 115, 145))
                    };
                    let title_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Rgb(125, 215, 255))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(210, 225, 235))
                    };
                    let detail_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(145, 175, 188))
                    } else {
                        Style::default().fg(Color::Rgb(118, 135, 150))
                    };
                    let preview_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(168, 188, 196))
                    } else {
                        Style::default().fg(Color::Rgb(132, 146, 156))
                    };

                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, Color::Rgb(110, 190, 255), Some(bg)),
                        Span::styled(format!("  {:<10}", entry.source), source_style),
                        Span::styled(unicode_trunc(&entry.display, 28), title_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.detail, 28), detail_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.preview, 34), preview_style),
                    ]))
                })
                .collect()
        };
        let list_border_style = if self.session_browser_pane.focus == SplitPaneFocus::List {
            Style::default()
                .fg(Color::Rgb(110, 190, 255))
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Rgb(60, 80, 84))
        };
        frame.render_widget(
            List::new(items)
                .style(Style::default().bg(Color::Rgb(18, 22, 28)))
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(list_border_style)
                        .title(" Sessions "),
                ),
            body[0],
        );

        let mut detail_lines = Vec::new();
        if let Some(entry) = self.session_browser.current() {
            detail_lines.push(Line::from(vec![
                Span::styled(
                    format!("{} ", entry.source),
                    Style::default()
                        .fg(Color::Rgb(110, 190, 255))
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(entry.display.clone()),
            ]));
            detail_lines.push(Line::from(""));
            for line in entry.detail_view.lines() {
                detail_lines.push(Line::from(line.to_string()));
            }
        } else if self.session_browser.query.trim().is_empty() {
            detail_lines.push(Line::from(Span::styled(
                "Session Browser",
                Style::default()
                    .fg(Color::Rgb(110, 190, 255))
                    .add_modifier(Modifier::BOLD),
            )));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Browse recent sessions on the left and inspect metadata on the right.",
            ));
            detail_lines.push(Line::from(
                "Search matches local metadata instantly and also checks the full indexed message archive.",
            ));
        } else {
            detail_lines.push(Line::from("No results for the current query."));
            detail_lines.push(Line::from(""));
            detail_lines.push(Line::from(
                "Try a title fragment, model name, source like cli or telegram, or terms from the conversation itself.",
            ));
        }
        self.render_scrollable_browser_detail(
            frame,
            body[1],
            ScrollableDetailChrome {
                title: "Details",
                border_color: Color::Rgb(110, 190, 255),
                focused: self.session_browser_pane.focus == SplitPaneFocus::Detail,
                requested_scroll: self.session_browser_pane.scroll,
            },
            detail_lines.clone(),
        );
        if self.detail_fullscreen_active(DetailSurface::SessionBrowser) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.session_browser.query,
                    header: BrowserChrome {
                        title: "Session Browser",
                        placeholder:
                            "Search by title, id, source, model, or any indexed message text.",
                        icon: "⏱",
                        icon_color: Color::Rgb(110, 190, 255),
                        border_color: Color::Rgb(110, 190, 255),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(110, 190, 255),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::SessionBrowser),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 190, 255))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(110, 190, 255))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(110, 190, 255)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 190, 255))),
                        Span::styled("inspect  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("R ", Style::default().fg(Color::Rgb(110, 190, 255))),
                        Span::styled("resume  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(110, 190, 255))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 190, 255))),
                        Span::styled("close  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        let note = self.session_browser_status_note.as_deref().unwrap_or(
            "Lowercase keeps typing in the filter. Uppercase shortcuts trigger actions.",
        );
        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("list  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Tab ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("focus pane  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
            self.paging_key_help_span(Color::Rgb(110, 190, 255)),
            Span::styled("page or scroll  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("inspect  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("resume  ", Style::default().fg(Color::DarkGray)),
            Span::styled("D ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("delete  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(110, 190, 255))),
            Span::styled("close  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} visible · {} pane · {}",
                    filtered.len(),
                    match self.session_browser_pane.focus {
                        SplitPaneFocus::List => "list",
                        SplitPaneFocus::Detail => "detail",
                    },
                    note
                ),
                Style::default().fg(Color::Rgb(100, 120, 130)),
            ),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn render_session_inspector(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let body = Self::browser_body_chunks(chunks[1]);
        self.render_browser_header(
            frame,
            chunks[0],
            &self.session_inspector.selector.query,
            BrowserChrome {
                title: "Session Inspector",
                placeholder:
                    "Filter this timeline by role, content, tool ids, tool names, or reasoning text.",
                icon: "⌕",
                icon_color: Color::Rgb(120, 215, 185),
                border_color: Color::Rgb(120, 215, 185),
            },
        );

        let selector = &self.session_inspector.selector;
        let filtered = &selector.filtered;
        let selected = selector.selected;
        let max_visible = Self::browser_list_visible_rows(body[0], true);
        let scroll_start = Self::browser_scroll_start(selected, max_visible);

        let items: Vec<ListItem> = if filtered.is_empty() {
            let empty_text = if selector.query.trim().is_empty() {
                "  No messages were stored for this session."
            } else {
                "  No messages in this session matched the current filter."
            };
            vec![ListItem::new(Line::from(Span::styled(
                empty_text.to_string(),
                Style::default().fg(Color::Rgb(120, 120, 135)),
            )))]
        } else {
            filtered
                .iter()
                .skip(scroll_start)
                .take(max_visible)
                .enumerate()
                .map(|(vis_idx, &entry_idx)| {
                    let entry = &selector.items[entry_idx];
                    let is_selected = vis_idx + scroll_start == selected;
                    let bg = if is_selected {
                        Color::Rgb(24, 36, 34)
                    } else {
                        Color::Rgb(18, 24, 24)
                    };
                    let role_color = match entry.role_label.as_str() {
                        "user" => Color::Rgb(110, 190, 255),
                        "assistant" => Color::Rgb(120, 215, 185),
                        "tool" => Color::Rgb(235, 190, 105),
                        "system" => Color::Rgb(190, 145, 240),
                        _ => Color::Rgb(190, 190, 190),
                    };
                    let index_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(145, 170, 170))
                    } else {
                        Style::default().fg(Color::Rgb(92, 112, 112))
                    };
                    let role_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(role_color)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(role_color)
                    };
                    let preview_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(218, 230, 228))
                    } else {
                        Style::default().fg(Color::Rgb(188, 198, 196))
                    };
                    let meta_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(145, 175, 170))
                    } else {
                        Style::default().fg(Color::Rgb(110, 136, 132))
                    };

                    ListItem::new(Line::from(vec![
                        selector_marker(is_selected, role_color, Some(bg)),
                        Span::styled(format!("  {:>3}", entry.index + 1), index_style),
                        Span::raw("  "),
                        Span::styled(format!("{:<10}", entry.role_label), role_style),
                        Span::styled(unicode_trunc(&entry.preview, 38), preview_style),
                        Span::raw("  "),
                        Span::styled(unicode_trunc(&entry.meta, 22), meta_style),
                    ]))
                })
                .collect()
        };
        let list_border_style = if self.session_inspector.pane.focus == SplitPaneFocus::List {
            Style::default()
                .fg(Color::Rgb(120, 215, 185))
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Rgb(60, 80, 84))
        };
        frame.render_widget(
            List::new(items)
                .style(Style::default().bg(Color::Rgb(18, 24, 24)))
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(list_border_style)
                        .title(" Timeline "),
                ),
            body[0],
        );

        let detail_lines = self.build_session_inspector_detail_lines();
        self.render_scrollable_browser_detail(
            frame,
            body[1],
            ScrollableDetailChrome {
                title: "Details",
                border_color: Color::Rgb(120, 215, 185),
                focused: self.session_inspector.pane.focus == SplitPaneFocus::Detail,
                requested_scroll: self.session_inspector.pane.scroll,
            },
            detail_lines.clone(),
        );
        if self.detail_fullscreen_active(DetailSurface::SessionInspector) {
            self.render_fullscreen_browser_detail(
                frame,
                area,
                FullscreenBrowserChrome {
                    query: &self.session_inspector.selector.query,
                    header: BrowserChrome {
                        title: "Session Inspector",
                        placeholder:
                            "Filter this timeline by role, content, tool ids, tool names, or reasoning text.",
                        icon: "⌕",
                        icon_color: Color::Rgb(120, 215, 185),
                        border_color: Color::Rgb(120, 215, 185),
                    },
                    detail: ScrollableDetailChrome {
                        title: "Details",
                        border_color: Color::Rgb(120, 215, 185),
                        focused: true,
                        requested_scroll: self
                            .detail_fullscreen_scroll(DetailSurface::SessionInspector),
                    },
                    help: Line::from(vec![
                        Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(120, 215, 185))),
                        Span::styled("change item  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("type ", Style::default().fg(Color::Rgb(120, 215, 185))),
                        Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
                        self.paging_key_help_span(Color::Rgb(120, 215, 185)),
                        Span::styled("scroll detail  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("R ", Style::default().fg(Color::Rgb(120, 215, 185))),
                        Span::styled("resume saved  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Z ", Style::default().fg(Color::Rgb(120, 215, 185))),
                        Span::styled("split view  ", Style::default().fg(Color::DarkGray)),
                        Span::styled("Esc ", Style::default().fg(Color::Rgb(120, 215, 185))),
                        Span::styled("back  ", Style::default().fg(Color::DarkGray)),
                    ]),
                },
                detail_lines,
            );
            return;
        }

        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("timeline  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Tab ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("focus pane  ", Style::default().fg(Color::DarkGray)),
            Span::styled("type ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("filter  ", Style::default().fg(Color::DarkGray)),
            self.paging_key_help_span(Color::Rgb(120, 215, 185)),
            Span::styled("page or scroll  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Z ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("detail  ", Style::default().fg(Color::DarkGray)),
            Span::styled("B ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("back  ", Style::default().fg(Color::DarkGray)),
            Span::styled("R ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("resume saved  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(120, 215, 185))),
            Span::styled("back  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!(
                    "{} visible · {} pane",
                    filtered.len(),
                    match self.session_inspector.pane.focus {
                        SplitPaneFocus::List => "list",
                        SplitPaneFocus::Detail => "detail",
                    }
                ),
                Style::default().fg(Color::Rgb(100, 120, 120)),
            ),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    /// Render a masked-input overlay for secret/sudo capture.
    ///
    /// The typed buffer is shown as `••••••••` so the secret never appears in
    /// plain text. The overlay is full-screen to prevent accidental shoulder-
    /// surfing from the scrollback buffer behind it.
    fn render_secret_capture_overlay(&self, frame: &mut Frame, area: Rect) {
        let (var_name, prompt, is_sudo, buffer_len) = if let DisplayState::SecretCapture {
            ref var_name,
            ref prompt,
            is_sudo,
            ref buffer,
        } = self.display_state
        {
            (var_name.as_str(), prompt.as_str(), is_sudo, buffer.len())
        } else {
            return;
        };

        frame.render_widget(Clear, area);

        // Centre a small dialog in the terminal
        let dlg_w = area.width.min(60);
        let dlg_h = 8u16;
        let x = area.x + (area.width.saturating_sub(dlg_w)) / 2;
        let y = area.y + (area.height.saturating_sub(dlg_h)) / 2;
        let dlg = Rect::new(x, y, dlg_w, dlg_h);

        let accent = if is_sudo {
            Color::Rgb(220, 80, 80)
        } else {
            Color::Rgb(80, 180, 220)
        };
        let icon = if is_sudo { "🔒" } else { "🔑" };

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(2), // prompt
                Constraint::Length(3), // input box
                Constraint::Length(1), // help
            ])
            .split(dlg);

        // Prompt line
        let prompt_para = Paragraph::new(Line::from(vec![
            Span::styled(format!("  {icon} "), Style::default().fg(accent)),
            Span::styled(
                prompt,
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
                .border_style(Style::default().fg(accent))
                .title(format!(" {} ", var_name)),
        );
        frame.render_widget(prompt_para, chunks[0]);

        // Masked input box
        let masked = "•".repeat(buffer_len);
        let input_para = Paragraph::new(Line::from(vec![
            Span::styled("  ", Style::default()),
            Span::styled(masked, Style::default().fg(Color::White)),
            Span::styled("█", Style::default().fg(accent)), // cursor
        ]))
        .block(
            Block::default()
                .borders(Borders::LEFT | Borders::BOTTOM | Borders::RIGHT)
                .border_style(Style::default().fg(accent)),
        );
        frame.render_widget(input_para, chunks[1]);

        // Help line
        let help = Paragraph::new(Line::from(vec![
            Span::styled("  Enter ", Style::default().fg(accent)),
            Span::styled("submit  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(accent)),
            Span::styled("abort", Style::default().fg(Color::DarkGray)),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn render_value_capture_overlay(&self, frame: &mut Frame, area: Rect) {
        let (title, prompt, placeholder, masked, buffer) = if let DisplayState::ValueCapture {
            ref title,
            ref prompt,
            ref placeholder,
            masked,
            ref buffer,
            ..
        } = self.display_state
        {
            (
                title.as_str(),
                prompt.as_str(),
                placeholder.as_str(),
                masked,
                buffer.as_str(),
            )
        } else {
            return;
        };

        frame.render_widget(Clear, area);

        let dlg_w = area.width.min(84);
        let dlg_h = 8u16;
        let x = area.x + (area.width.saturating_sub(dlg_w)) / 2;
        let y = area.y + (area.height.saturating_sub(dlg_h)) / 2;
        let dlg = Rect::new(x, y, dlg_w, dlg_h);
        let accent = Color::Rgb(120, 220, 200);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(2),
                Constraint::Length(3),
                Constraint::Length(1),
            ])
            .split(dlg);

        let prompt_para = Paragraph::new(Line::from(vec![
            Span::styled("  ⛵ ", Style::default().fg(accent)),
            Span::styled(
                prompt,
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            ),
        ]))
        .wrap(Wrap { trim: false })
        .block(
            Block::default()
                .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
                .border_style(Style::default().fg(accent))
                .title(format!(" {} ", title)),
        );
        frame.render_widget(prompt_para, chunks[0]);

        let visible = if buffer.is_empty() {
            placeholder.to_string()
        } else if masked {
            "•".repeat(buffer.chars().count())
        } else {
            buffer.to_string()
        };
        let input_style = if buffer.is_empty() {
            Style::default().fg(Color::DarkGray)
        } else {
            Style::default().fg(Color::White)
        };
        let input_para = Paragraph::new(Line::from(vec![
            Span::styled("  ", Style::default()),
            Span::styled(visible, input_style),
            Span::styled("█", Style::default().fg(accent)),
        ]))
        .block(
            Block::default()
                .borders(Borders::LEFT | Borders::BOTTOM | Borders::RIGHT)
                .border_style(Style::default().fg(accent)),
        );
        frame.render_widget(input_para, chunks[1]);

        let help = Paragraph::new(Line::from(vec![
            Span::styled("  Enter ", Style::default().fg(accent)),
            Span::styled("save  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(accent)),
            Span::styled("cancel", Style::default().fg(Color::DarkGray)),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn render_document_overlay(&self, frame: &mut Frame, area: Rect) {
        let Some(overlay) = self.document_overlay.as_ref() else {
            return;
        };

        frame.render_widget(Clear, area);

        let chunks = Self::browser_overlay_chunks(area);
        let header = Paragraph::new(Line::from(vec![
            Span::styled(
                format!("  {} ", overlay.icon),
                Style::default().fg(overlay.accent),
            ),
            Span::styled(
                if overlay.subtitle.trim().is_empty() {
                    "Read-only report".to_string()
                } else {
                    overlay.subtitle.clone()
                },
                Style::default().fg(Color::White),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(overlay.accent))
                .title(format!(" {} ", overlay.title)),
        );
        frame.render_widget(header, chunks[0]);

        let detail_lines: Vec<Line<'static>> = overlay
            .body
            .lines()
            .map(|line| Line::from(line.to_string()))
            .collect();
        self.render_scrollable_browser_detail(
            frame,
            chunks[1],
            ScrollableDetailChrome {
                title: "Details",
                border_color: overlay.accent,
                focused: true,
                requested_scroll: overlay.scroll,
            },
            detail_lines,
        );

        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ↑↓ ", Style::default().fg(overlay.accent)),
            Span::styled("scroll line  ", Style::default().fg(Color::DarkGray)),
            self.paging_key_help_span(overlay.accent),
            Span::styled("scroll page  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Home/End ", Style::default().fg(overlay.accent)),
            Span::styled("jump  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(overlay.accent)),
            Span::styled("close", Style::default().fg(Color::DarkGray)),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    fn render_skin_browser(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Clear, area);

        let accent = Color::Rgb(255, 150, 80); // warm tangerine
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3), // search input
                Constraint::Min(1),    // skin list
                Constraint::Length(1), // help line
            ])
            .split(area);

        // ── Search box ───────────────────────────────────────────────
        let search_text = if self.skin_browser.query.is_empty() {
            "Type to filter skins…  (Esc to cancel)".to_string()
        } else {
            self.skin_browser.query.clone()
        };
        let search_style = if self.skin_browser.query.is_empty() {
            Style::default().fg(Color::DarkGray)
        } else {
            Style::default().fg(Color::White)
        };
        let search = Paragraph::new(Line::from(vec![
            Span::styled("  🎨 ", Style::default().fg(accent)),
            Span::styled(search_text, search_style),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(accent))
                .title(" Browse Skins  [/skin] "),
        );
        frame.render_widget(search, chunks[0]);

        // ── Skin list ─────────────────────────────────────────────────
        let max_visible = chunks[1].height as usize;
        let filtered = &self.skin_browser.filtered;
        let selected = self.skin_browser.selected;

        let scroll_start = if selected >= max_visible {
            selected - max_visible + 1
        } else {
            0
        };

        let name_w = 20usize;

        let items: Vec<ListItem> = filtered
            .iter()
            .skip(scroll_start)
            .take(max_visible)
            .enumerate()
            .map(|(vis_idx, &entry_idx)| {
                let entry = &self.skin_browser.items[entry_idx];
                let is_selected = vis_idx + scroll_start == selected;

                let name_cell = unicode_pad_right(&entry.name, name_w);
                let badge = if entry.is_active { " ✓ active" } else { "" };

                let bg = if is_selected {
                    Color::Rgb(60, 40, 20)
                } else {
                    Color::Reset
                };
                let name_fg = if is_selected {
                    Color::White
                } else {
                    Color::Rgb(220, 180, 100)
                };
                let badge_fg = Color::Rgb(100, 200, 100);

                ListItem::new(Line::from(vec![
                    selector_marker(is_selected, accent, Some(bg)),
                    Span::styled(
                        format!("  {name_cell}"),
                        Style::default().fg(name_fg).bg(bg),
                    ),
                    Span::styled(badge, Style::default().fg(badge_fg).bg(bg)),
                ]))
            })
            .collect();

        let skin_list =
            List::new(items).block(Block::default().borders(Borders::LEFT | Borders::RIGHT));
        frame.render_widget(skin_list, chunks[1]);

        // ── Help line ─────────────────────────────────────────────────
        let help = Paragraph::new(Line::from(vec![
            Span::styled("  ↑↓ ", Style::default().fg(accent)),
            Span::styled("navigate  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(accent)),
            Span::styled("apply  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(accent)),
            Span::styled("cancel", Style::default().fg(Color::DarkGray)),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    /// Render the risk-graduated approval overlay.
    ///
    /// Layout:
    /// ```text
    ///   ┌─ Approval required ──────────────────────────────────┐
    ///   │                                                       │
    ///   │  ⚠  rm -rf /tmp/build                               │
    ///   │                                                       │
    ///   │   > [once]  [session]  [always]  [deny]  [v]iew      │
    ///   │                                                       │
    ///   │  ← → select  Enter confirm  v view  Esc deny         │
    ///   └───────────────────────────────────────────────────────┘
    /// ```
    fn render_verbose_selector(&self, frame: &mut Frame, area: Rect) {
        // Compact centered popup — 4 mode rows + header + detail + help.
        let popup = popup_rect(area, 72, 18);
        frame.render_widget(Clear, popup);
        let chunks = picker_three_layout(popup); // header(3) | body(min) | help(1)
        let body = picker_two_cols(chunks[1], 45); // list | detail

        // ── Modes metadata ───────────────────────────────────────
        const MODES: [(ToolProgressMode, &str, &str, &str); 4] = [
            (
                ToolProgressMode::Off,
                "OFF",
                "⊘",
                "Silent — only the status bar shows active work.",
            ),
            (
                ToolProgressMode::New,
                "NEW",
                "◑",
                "Show each distinct tool call once per turn.",
            ),
            (
                ToolProgressMode::All,
                "ALL",
                "●",
                "Show every tool call in the transcript.",
            ),
            (
                ToolProgressMode::Verbose,
                "VERBOSE",
                "◉",
                "Show every call + curated plan and result detail lines.",
            ),
        ];
        let active_mode = self.tool_progress_mode;
        let cursor = self.verbose_selector_cursor;

        // ── Header block ─────────────────────────────────────────
        let active_label = MODES
            .iter()
            .find(|(m, _, _, _)| *m == active_mode)
            .map(|(_, lbl, _, _)| *lbl)
            .unwrap_or("?");
        let header = Paragraph::new(Line::from(vec![
            Span::styled("  ◈  ", Style::default().fg(Color::Rgb(130, 210, 255))),
            Span::styled(
                "Tool Progress Display",
                Style::default()
                    .fg(Color::Rgb(200, 225, 255))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(
                format!("current: {active_label}"),
                Style::default().fg(Color::Rgb(100, 170, 220)),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(100, 160, 210)))
                .title(" /verbose "),
        );
        frame.render_widget(header, chunks[0]);

        // ── Mode list ────────────────────────────────────────────
        let list_items: Vec<ListItem> = MODES
            .iter()
            .enumerate()
            .map(|(i, (mode, label, icon, _desc))| {
                let is_cursor = i == cursor;
                let is_active = *mode == active_mode;
                let bg = if is_cursor {
                    Color::Rgb(22, 38, 55)
                } else {
                    Color::Rgb(15, 18, 24)
                };

                let marker = selector_marker(is_cursor, Color::Rgb(130, 210, 255), Some(bg));

                let active_badge = if is_active {
                    Span::styled(" ◉", Style::default().fg(Color::Rgb(100, 200, 130)))
                } else {
                    Span::styled(" ○", Style::default().fg(Color::Rgb(55, 65, 80)))
                };

                let icon_style = if is_cursor {
                    Style::default().bg(bg).fg(Color::Rgb(130, 210, 255))
                } else if is_active {
                    Style::default().fg(Color::Rgb(100, 200, 130))
                } else {
                    Style::default().fg(Color::Rgb(80, 95, 115))
                };
                let label_style = if is_cursor {
                    Style::default()
                        .bg(bg)
                        .fg(Color::Rgb(200, 225, 255))
                        .add_modifier(Modifier::BOLD)
                } else if is_active {
                    Style::default().fg(Color::Rgb(160, 210, 170))
                } else {
                    Style::default().fg(Color::Rgb(155, 165, 185))
                };

                ListItem::new(Line::from(vec![
                    marker,
                    active_badge,
                    Span::styled(format!("  {icon} "), icon_style),
                    Span::styled(unicode_pad_right(label, 9), label_style),
                ]))
            })
            .collect();

        let list = List::new(list_items).style(Style::default().bg(Color::Rgb(15, 18, 24)));
        frame.render_widget(list, body[0]);

        // ── Detail panel for highlighted mode ────────────────────
        let (_, detail_label, detail_icon, detail_desc) = &MODES[cursor];
        let is_active_cursor = MODES[cursor].0 == active_mode;
        let mut detail_lines: Vec<Line<'static>> = Vec::new();

        // Mode name heading
        detail_lines.push(Line::from(vec![
            Span::styled(
                format!("{detail_icon} {detail_label}"),
                Style::default()
                    .fg(Color::Rgb(130, 210, 255))
                    .add_modifier(Modifier::BOLD),
            ),
            if is_active_cursor {
                Span::styled("  ◉ active", Style::default().fg(Color::Rgb(100, 200, 130)))
            } else {
                Span::raw("")
            },
        ]));
        detail_lines.push(Line::from(""));

        // Description — word-wrap at panel width
        detail_lines.push(Line::from(Span::styled(
            detail_desc.to_string(),
            Style::default().fg(Color::Rgb(185, 200, 220)),
        )));
        detail_lines.push(Line::from(""));

        // What pressing Enter will do
        if is_active_cursor {
            detail_lines.push(Line::from(Span::styled(
                "Already active — Enter closes.",
                Style::default().fg(Color::Rgb(110, 125, 140)),
            )));
        } else {
            detail_lines.push(Line::from(Span::styled(
                "Press Enter to switch to this mode.",
                Style::default().fg(Color::Rgb(130, 210, 255)),
            )));
        }

        let detail = Paragraph::new(Text::from(detail_lines))
            .wrap(Wrap { trim: false })
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::Rgb(60, 85, 110)))
                    .title(" Details "),
            );
        frame.render_widget(detail, body[1]);

        // ── Help bar ─────────────────────────────────────────────
        frame.render_widget(
            Paragraph::new(picker_help_line(Color::Rgb(130, 210, 255))),
            chunks[2],
        );
    }

    /// Render the reasoning settings picker overlay.
    ///
    /// 5 options: Low / Medium / High effort (API) and Show / Hide reasoning trace.
    fn render_reasoning_selector(&self, frame: &mut Frame, area: Rect) {
        let popup = popup_rect(area, 72, 20);
        frame.render_widget(Clear, popup);
        let chunks = picker_three_layout(popup);
        let body = picker_two_cols(chunks[1], 42);

        const ENTRIES: [(&str, &str, &str, &str); 5] = [
            ("low", "LOW", "◌", "Faster & cheaper. Less analytic depth."),
            ("medium", "MEDIUM", "◎", "Balanced — good for most tasks."),
            (
                "high",
                "HIGH",
                "●",
                "Deeper reasoning. Slower, more tokens.",
            ),
            (
                "show",
                "SHOW",
                "o",
                "Reveal the reasoning trace above answers.",
            ),
            ("hide", "HIDE", "x", "Keep the reasoning trace hidden."),
        ];

        let accent = Color::Rgb(130, 210, 255);
        let cursor = self.reasoning_selector_cursor;
        let cur_effort = self.reasoning_effort_hint.as_deref().unwrap_or("medium");
        let cur_vis = if self.show_reasoning {
            "shown"
        } else {
            "hidden"
        };

        let header = Paragraph::new(Line::from(vec![
            Span::styled("  ◈  ", Style::default().fg(accent)),
            Span::styled(
                "Reasoning Settings",
                Style::default()
                    .fg(Color::Rgb(200, 225, 255))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(
                format!("effort: {}  trace: {}", cur_effort.to_uppercase(), cur_vis),
                Style::default().fg(Color::Rgb(100, 170, 220)),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(100, 160, 210)))
                .title(" /reasoning "),
        );
        frame.render_widget(header, chunks[0]);

        // ── List panel ───────────────────────────────────────────
        let items: Vec<ListItem> = ENTRIES
            .iter()
            .enumerate()
            .map(|(i, (_, label, icon, _))| {
                let is_cursor = i == cursor;
                let bg = if is_cursor {
                    Color::Rgb(40, 60, 80)
                } else {
                    Color::Reset
                };
                let fg = if is_cursor {
                    Color::White
                } else {
                    Color::Rgb(180, 200, 220)
                };
                ListItem::new(Line::from(vec![
                    selector_marker(is_cursor, accent, Some(bg)),
                    Span::styled(format!(" {icon} "), Style::default().fg(accent).bg(bg)),
                    Span::styled(
                        format!("{label:<8}", label = *label),
                        Style::default().fg(fg).bg(bg),
                    ),
                ]))
            })
            .collect();
        let list = List::new(items).block(Block::default().borders(Borders::LEFT | Borders::RIGHT));
        frame.render_widget(list, body[0]);

        // ── Detail panel ─────────────────────────────────────────
        let (key, label, icon, desc) = ENTRIES[cursor];
        let current_active = match cursor {
            0 => self.reasoning_effort_hint.as_deref() == Some("low"),
            1 => self.reasoning_effort_hint.as_deref().unwrap_or("medium") == "medium",
            2 => self.reasoning_effort_hint.as_deref() == Some("high"),
            3 => self.show_reasoning,
            4 => !self.show_reasoning,
            _ => false,
        };
        let _ = key; // used via cursor index
        let action_hint = if current_active {
            "Already active"
        } else {
            "Press Enter to apply"
        };
        let detail = Paragraph::new(vec![
            Line::from(""),
            Line::from(Span::styled(
                format!("  {icon} {label}"),
                Style::default()
                    .fg(Color::Rgb(200, 225, 255))
                    .add_modifier(Modifier::BOLD),
            )),
            Line::from(""),
            Line::from(Span::styled(
                format!("  {desc}"),
                Style::default().fg(Color::Rgb(170, 195, 215)),
            )),
            Line::from(""),
            Line::from(Span::styled(
                format!("  {action_hint}"),
                Style::default().fg(if current_active {
                    Color::Rgb(100, 200, 100)
                } else {
                    Color::Rgb(220, 180, 80)
                }),
            )),
        ])
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(60, 90, 120))),
        );
        frame.render_widget(detail, body[1]);

        // ── Help bar ─────────────────────────────────────────────
        frame.render_widget(Paragraph::new(picker_help_line(accent)), chunks[2]);
    }

    /// Render the personality picker overlay.
    ///
    /// Shows all personality presets with a preview pane. The first entry
    /// is always "clear" to remove any active personality overlay.
    fn render_personality_selector(&self, frame: &mut Frame, area: Rect) {
        let popup = popup_rect(area, 76, 22);
        frame.render_widget(Clear, popup);
        let chunks = picker_three_layout(popup);
        let body = picker_two_cols(chunks[1], 38);

        let accent = Color::Rgb(200, 160, 255);
        let cursor = self.personality_selector_cursor;
        let entries = &self.personality_selector_entries;
        let cur_personality = self.session_personality.as_deref().unwrap_or("default");

        // ── Header ───────────────────────────────────────────────
        let header = Paragraph::new(Line::from(vec![
            Span::styled("  ◈  ", Style::default().fg(accent)),
            Span::styled(
                "Personality",
                Style::default()
                    .fg(Color::Rgb(225, 200, 255))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(
                format!("active: {cur_personality}"),
                Style::default().fg(Color::Rgb(170, 140, 220)),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(150, 100, 200)))
                .title(" /personality "),
        );
        frame.render_widget(header, chunks[0]);

        // ── Scrolling list panel ──────────────────────────────────
        let list_height = body[0].height.saturating_sub(2) as usize;
        let total = entries.len();
        let scroll_start = if total > list_height && cursor >= list_height {
            cursor.saturating_sub(list_height.saturating_sub(1))
        } else {
            0
        };
        let name_w = body[0].width.saturating_sub(6) as usize;
        let items: Vec<ListItem> = entries
            .iter()
            .enumerate()
            .skip(scroll_start)
            .take(list_height)
            .map(|(i, (name, _))| {
                let is_cursor = i == cursor;
                let is_active = name == "clear" && self.session_personality.is_none()
                    || self.session_personality.as_deref() == Some(name.as_str());
                let bg = if is_cursor {
                    Color::Rgb(60, 30, 80)
                } else {
                    Color::Reset
                };
                let fg = if is_cursor {
                    Color::White
                } else {
                    Color::Rgb(200, 170, 230)
                };
                let badge_fg = Color::Rgb(100, 200, 100);
                let name_cell = unicode_trunc(name, name_w.max(1));
                let badge = if is_active { " ✓" } else { "" };
                ListItem::new(Line::from(vec![
                    selector_marker(is_cursor, accent, Some(bg)),
                    Span::styled(format!("  {name_cell}"), Style::default().fg(fg).bg(bg)),
                    Span::styled(badge, Style::default().fg(badge_fg).bg(bg)),
                ]))
            })
            .collect();
        let list = List::new(items).block(Block::default().borders(Borders::LEFT | Borders::RIGHT));
        frame.render_widget(list, body[0]);

        // ── Detail panel ─────────────────────────────────────────
        if let Some((name, preview)) = entries.get(cursor) {
            let is_active = (name == "clear" && self.session_personality.is_none())
                || self.session_personality.as_deref() == Some(name.as_str());
            let action_hint = if is_active {
                "Already active"
            } else {
                "Press Enter to apply"
            };
            let preview_short = edgecrab_core::safe_truncate(preview, 240);
            let detail = Paragraph::new(vec![
                Line::from(""),
                Line::from(Span::styled(
                    format!("  {name}"),
                    Style::default()
                        .fg(Color::Rgb(225, 200, 255))
                        .add_modifier(Modifier::BOLD),
                )),
                Line::from(""),
                Line::from(Span::styled(
                    format!("  {preview_short}"),
                    Style::default().fg(Color::Rgb(195, 175, 215)),
                )),
                Line::from(""),
                Line::from(Span::styled(
                    format!("  {action_hint}"),
                    Style::default().fg(if is_active {
                        Color::Rgb(100, 200, 100)
                    } else {
                        Color::Rgb(220, 180, 80)
                    }),
                )),
            ])
            .wrap(ratatui::widgets::Wrap { trim: true })
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::Rgb(90, 60, 120))),
            );
            frame.render_widget(detail, body[1]);
        }

        // ── Help bar ─────────────────────────────────────────────
        frame.render_widget(Paragraph::new(picker_help_line(accent)), chunks[2]);
    }

    /// Render the streaming mode picker (2 options: on / off).
    fn render_stream_selector(&self, frame: &mut Frame, area: Rect) {
        let popup = popup_rect(area, 64, 16);
        frame.render_widget(Clear, popup);
        let chunks = picker_three_layout(popup);
        let body = picker_two_cols(chunks[1], 38);

        const ENTRIES: [(&str, &str, &str); 2] = [
            ("on", "ON", "Tokens stream live as they are generated."),
            ("off", "OFF", "Replies appear as one complete message."),
        ];

        let accent = Color::Rgb(255, 210, 80);
        let cursor = self.stream_selector_cursor;
        let cur_label = if self.streaming_enabled { "on" } else { "off" };

        let header = Paragraph::new(Line::from(vec![
            Span::styled("  ◈  ", Style::default().fg(accent)),
            Span::styled(
                "Token Streaming",
                Style::default()
                    .fg(Color::Rgb(255, 235, 160))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(
                format!("current: {cur_label}"),
                Style::default().fg(Color::Rgb(200, 170, 80)),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(180, 150, 60)))
                .title(" /stream "),
        );
        frame.render_widget(header, chunks[0]);

        let items: Vec<ListItem> = ENTRIES
            .iter()
            .enumerate()
            .map(|(i, (key, label, _))| {
                let is_cursor = i == cursor;
                let is_active = *key == cur_label;
                let bg = if is_cursor {
                    Color::Rgb(70, 55, 20)
                } else {
                    Color::Reset
                };
                let fg = if is_cursor {
                    Color::White
                } else {
                    Color::Rgb(230, 200, 140)
                };
                let badge_fg = Color::Rgb(100, 200, 100);
                ListItem::new(Line::from(vec![
                    selector_marker(is_cursor, accent, Some(bg)),
                    Span::styled(
                        format!("  {label:<5}", label = *label),
                        Style::default().fg(fg).bg(bg),
                    ),
                    Span::styled(
                        if is_active { " ✓" } else { "" },
                        Style::default().fg(badge_fg).bg(bg),
                    ),
                ]))
            })
            .collect();
        let list = List::new(items).block(Block::default().borders(Borders::LEFT | Borders::RIGHT));
        frame.render_widget(list, body[0]);

        let (key, label, desc) = ENTRIES[cursor];
        let is_active_cur = key == cur_label;
        let action_hint = if is_active_cur {
            "Already active"
        } else {
            "Press Enter to switch"
        };
        let detail = Paragraph::new(vec![
            Line::from(""),
            Line::from(Span::styled(
                format!("  {label}"),
                Style::default()
                    .fg(Color::Rgb(255, 235, 160))
                    .add_modifier(Modifier::BOLD),
            )),
            Line::from(""),
            Line::from(Span::styled(
                format!("  {desc}"),
                Style::default().fg(Color::Rgb(210, 185, 130)),
            )),
            Line::from(""),
            Line::from(Span::styled(
                format!("  {action_hint}"),
                Style::default().fg(if is_active_cur {
                    Color::Rgb(100, 200, 100)
                } else {
                    Color::Rgb(220, 180, 80)
                }),
            )),
        ])
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(100, 80, 30))),
        );
        frame.render_widget(detail, body[1]);

        frame.render_widget(Paragraph::new(picker_help_line(accent)), chunks[2]);
    }

    /// Render the status bar visibility picker (2 options: visible / hidden).
    fn render_statusbar_selector(&self, frame: &mut Frame, area: Rect) {
        let popup = popup_rect(area, 64, 16);
        frame.render_widget(Clear, popup);
        let chunks = picker_three_layout(popup);
        let body = picker_two_cols(chunks[1], 38);

        const ENTRIES: [(&str, &str, &str); 2] = [
            (
                "visible",
                "Visible",
                "Shows model, context pressure, and metrics.",
            ),
            (
                "hidden",
                "Hidden",
                "Clean mode — full height for conversation.",
            ),
        ];

        let accent = Color::Rgb(120, 200, 200);
        let cursor = self.statusbar_selector_cursor;
        let cur_label = if self.show_status_bar {
            "visible"
        } else {
            "hidden"
        };

        let header = Paragraph::new(Line::from(vec![
            Span::styled("  ◈  ", Style::default().fg(accent)),
            Span::styled(
                "Status Bar",
                Style::default()
                    .fg(Color::Rgb(180, 230, 230))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(
                format!("current: {cur_label}"),
                Style::default().fg(Color::Rgb(100, 170, 170)),
            ),
        ]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(80, 160, 160)))
                .title(" /statusbar "),
        );
        frame.render_widget(header, chunks[0]);

        let items: Vec<ListItem> = ENTRIES
            .iter()
            .enumerate()
            .map(|(i, (key, label, _))| {
                let is_cursor = i == cursor;
                let is_active = *key == cur_label;
                let bg = if is_cursor {
                    Color::Rgb(20, 60, 60)
                } else {
                    Color::Reset
                };
                let fg = if is_cursor {
                    Color::White
                } else {
                    Color::Rgb(160, 210, 210)
                };
                let badge_fg = Color::Rgb(100, 200, 100);
                ListItem::new(Line::from(vec![
                    selector_marker(is_cursor, accent, Some(bg)),
                    Span::styled(format!("  {label}"), Style::default().fg(fg).bg(bg)),
                    Span::styled(
                        if is_active { " ✓" } else { "" },
                        Style::default().fg(badge_fg).bg(bg),
                    ),
                ]))
            })
            .collect();
        let list = List::new(items).block(Block::default().borders(Borders::LEFT | Borders::RIGHT));
        frame.render_widget(list, body[0]);

        let (key, label, desc) = ENTRIES[cursor];
        let is_active_cur = key == cur_label;
        let action_hint = if is_active_cur {
            "Already active"
        } else {
            "Press Enter to switch"
        };
        let detail = Paragraph::new(vec![
            Line::from(""),
            Line::from(Span::styled(
                format!("  {label}"),
                Style::default()
                    .fg(Color::Rgb(180, 230, 230))
                    .add_modifier(Modifier::BOLD),
            )),
            Line::from(""),
            Line::from(Span::styled(
                format!("  {desc}"),
                Style::default().fg(Color::Rgb(150, 200, 200)),
            )),
            Line::from(""),
            Line::from(Span::styled(
                format!("  {action_hint}"),
                Style::default().fg(if is_active_cur {
                    Color::Rgb(100, 200, 100)
                } else {
                    Color::Rgb(220, 180, 80)
                }),
            )),
        ])
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(50, 110, 110))),
        );
        frame.render_widget(detail, body[1]);

        frame.render_widget(Paragraph::new(picker_help_line(accent)), chunks[2]);
    }

    /// Render the compact mission-steering overlay.
    ///
    /// The overlay appears as a small floating panel over the lower-half of the
    /// output area when `steering_overlay_active` is true.  It contains:
    ///   – A header showing the active steering kind with Tab cycling hint
    ///   – A single-line textarea for the steer message
    ///   – A footer help bar
    fn render_steering_overlay(&mut self, frame: &mut Frame, area: Rect) {
        use edgecrab_core::SteeringKind;

        // Compact popup: 60 wide × 7 tall — below centre so output stays visible.
        let pw: u16 = 60.min(area.width);
        let ph: u16 = 7;
        let popup = Rect {
            x: area.x + area.width.saturating_sub(pw) / 2,
            // Position at 70 % down the screen so the output is not obscured.
            y: area.y + (area.height.saturating_sub(ph) * 7 / 10),
            width: pw,
            height: ph,
        };
        frame.render_widget(Clear, popup);

        // Render the border block and obtain the inner area to layout within.
        let border_block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Rgb(120, 180, 255)))
            .title(Span::styled(
                " ⛵ Mission Steer ",
                Style::default()
                    .fg(Color::Rgb(200, 230, 255))
                    .add_modifier(Modifier::BOLD),
            ));
        let inner = border_block.inner(popup);
        frame.render_widget(border_block, popup);

        // Split inner area: header(1) | textarea(min) | help(1)
        let sections = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(1), // kind selector header
                Constraint::Min(1),    // textarea
                Constraint::Length(1), // help bar
            ])
            .split(inner);

        // ── Kind selector row ─────────────────────────────────────────────
        let (hint_s, redir_s, stop_s) = match self.steering_kind {
            SteeringKind::Hint => (
                Style::default()
                    .fg(Color::Rgb(10, 18, 30))
                    .bg(Color::Rgb(120, 220, 165))
                    .add_modifier(Modifier::BOLD),
                Style::default().fg(Color::Rgb(90, 110, 140)),
                Style::default().fg(Color::Rgb(90, 110, 140)),
            ),
            SteeringKind::Redirect => (
                Style::default().fg(Color::Rgb(90, 110, 140)),
                Style::default()
                    .fg(Color::Rgb(10, 18, 30))
                    .bg(Color::Rgb(255, 200, 80))
                    .add_modifier(Modifier::BOLD),
                Style::default().fg(Color::Rgb(90, 110, 140)),
            ),
            SteeringKind::Stop => (
                Style::default().fg(Color::Rgb(90, 110, 140)),
                Style::default().fg(Color::Rgb(90, 110, 140)),
                Style::default()
                    .fg(Color::Rgb(10, 18, 30))
                    .bg(Color::Rgb(255, 100, 80))
                    .add_modifier(Modifier::BOLD),
            ),
        };
        let kind_line = Line::from(vec![
            Span::styled("  kind: ", Style::default().fg(Color::Rgb(130, 150, 180))),
            Span::styled(" HINT ", hint_s),
            Span::raw(" "),
            Span::styled(" REDIRECT ", redir_s),
            Span::raw(" "),
            Span::styled(" STOP ", stop_s),
            Span::styled(
                "  (Tab=cycle)",
                Style::default().fg(Color::Rgb(70, 85, 110)),
            ),
        ]);
        frame.render_widget(Paragraph::new(kind_line), sections[0]);

        // ── Steering textarea ─────────────────────────────────────────────
        self.steering_textarea.set_style(
            Style::default()
                .fg(Color::Rgb(220, 235, 255))
                .bg(Color::Rgb(16, 20, 30)),
        );
        frame.render_widget(&self.steering_textarea, sections[1]);

        // ── Help bar ──────────────────────────────────────────────────────
        let help = Line::from(vec![
            Span::styled(
                " Enter",
                Style::default()
                    .fg(Color::Rgb(120, 220, 165))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(" send  ", Style::default().fg(Color::Rgb(100, 130, 160))),
            Span::styled(
                "Tab",
                Style::default()
                    .fg(Color::Rgb(120, 220, 165))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(" kind  ", Style::default().fg(Color::Rgb(100, 130, 160))),
            Span::styled(
                "Esc",
                Style::default()
                    .fg(Color::Rgb(200, 100, 80))
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(" cancel ", Style::default().fg(Color::Rgb(100, 130, 160))),
        ]);
        frame.render_widget(Paragraph::new(help), sections[2]);
    }

    fn render_approval_overlay(&self, frame: &mut Frame, area: Rect) {
        // Only render when in WaitingForApproval state
        let (command, full_command, selected, scroll_offset) =
            if let DisplayState::WaitingForApproval {
                ref command,
                ref full_command,
                selected,
                scroll_offset,
                ..
            } = self.display_state
            {
                (
                    command.as_str(),
                    full_command.as_str(),
                    selected,
                    scroll_offset,
                )
            } else {
                return;
            };

        frame.render_widget(Clear, area);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(1),    // command display (wrapped + scrollable)
                Constraint::Length(3), // choice buttons
                Constraint::Length(1), // help line
            ])
            .split(area);

        // ── Command display — always show full command, wrapped and scrollable ──
        // Use full_command if it differs from command; otherwise command is already full.
        let cmd_text = if full_command.is_empty() {
            command
        } else {
            full_command
        };
        let cmd_lines: Vec<Line> = cmd_text
            .lines()
            .map(|l| {
                Line::from(vec![
                    Span::styled(
                        "  ⚠  ",
                        Style::default()
                            .fg(Color::Rgb(255, 140, 0))
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(
                        l.to_string(),
                        Style::default().fg(Color::Rgb(255, 220, 180)),
                    ),
                ])
            })
            .collect();
        let cmd_para = Paragraph::new(cmd_lines)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::Rgb(255, 140, 0)))
                    .title(" ⚠  Approval required "),
            )
            .wrap(Wrap { trim: false })
            .scroll((scroll_offset, 0));
        frame.render_widget(cmd_para, chunks[0]);

        // ── Choice buttons ───────────────────────────────────────────
        const LABELS: [&str; 4] = ["once", "session", "always", "deny"];
        let mut btn_spans: Vec<Span> = vec![Span::raw("  ")];
        for (i, label) in LABELS.iter().enumerate() {
            let is_sel = i == selected;
            let style = if is_sel {
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Rgb(255, 140, 0))
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Rgb(180, 180, 200))
            };
            btn_spans.push(Span::styled(format!(" [{label}] "), style));
            btn_spans.push(Span::raw(" "));
        }

        let buttons = Paragraph::new(Line::from(btn_spans)).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Rgb(80, 80, 100))),
        );
        frame.render_widget(buttons, chunks[1]);

        // ── Help line ────────────────────────────────────────────────
        let help = Paragraph::new(Line::from(vec![
            Span::styled(" ← → ", Style::default().fg(Color::Rgb(255, 140, 0))),
            Span::styled("select  ", Style::default().fg(Color::DarkGray)),
            Span::styled("↑ ↓ ", Style::default().fg(Color::Rgb(255, 140, 0))),
            Span::styled("scroll  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Enter ", Style::default().fg(Color::Rgb(255, 140, 0))),
            Span::styled("confirm  ", Style::default().fg(Color::DarkGray)),
            Span::styled("Esc ", Style::default().fg(Color::Rgb(255, 140, 0))),
            Span::styled("deny", Style::default().fg(Color::DarkGray)),
        ]));
        frame.render_widget(help, chunks[2]);
    }

    /// Render the input box + completion overlay + ghost text.
    fn render_input(&mut self, frame: &mut Frame, area: Rect) {
        // Configure the block before rendering so mode and pending-operator
        // feedback appear immediately on the current frame.
        let text = self.textarea_text();
        let block = if self.is_processing {
            // FP53: Animate the waiting title using the same braille spinner frame
            // as the status bar — zero extra state, perfect sync.
            let spinner = SPINNER_FRAMES[self.current_spinner_frame() % SPINNER_FRAMES.len()];
            let waiting_label = format!("{spinner} waiting…");
            Block::default()
                .borders(Borders::ALL)
                .border_style(
                    Style::default()
                        .fg(Color::Rgb(60, 60, 75))
                        .add_modifier(Modifier::DIM),
                )
                .title(self.input_panel_title(&waiting_label))
        } else if text.starts_with('/') {
            let cmd_name = text.split_whitespace().next().unwrap_or("");
            let is_valid = self.all_command_names.iter().any(|c| c == cmd_name);
            let border_color = if is_valid {
                Color::Cyan
            } else if cmd_name.len() > 1 {
                Color::Rgb(239, 83, 80)
            } else {
                self.theme.input_border.fg.unwrap_or(Color::White)
            };
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(border_color))
                .title(self.input_panel_title(&self.theme.prompt_symbol))
        } else if text.starts_with('@') {
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Green))
                .title(self.input_panel_title(&self.theme.prompt_symbol))
        } else {
            Block::default()
                .borders(Borders::ALL)
                .border_style(self.theme.input_border)
                .title(self.input_panel_title(&self.theme.prompt_symbol))
        };
        self.textarea.set_block(block);
        frame.render_widget(&self.textarea, area);

        // Ghost text overlay (Fish-style hint)
        if self.show_ghost_hint
            && matches!(self.editor_mode, InputEditorMode::Inline)
            && let Some(hint) = self.ghost_hint()
        {
            let (row, col) = self.textarea.cursor();
            let ghost_x = area.x + 1 + col as u16; // +1 for border
            let ghost_y = area.y + 1 + row as u16;
            if ghost_x < area.x + area.width - 1 {
                let max_width = (area.x + area.width - 1 - ghost_x) as usize;
                let display = edgecrab_core::safe_truncate(&hint, max_width);
                let ghost_area = Rect::new(ghost_x, ghost_y, display.len() as u16, 1);
                let ghost = Paragraph::new(Span::styled(
                    display.to_string(),
                    Style::default().fg(Color::DarkGray),
                ));
                frame.render_widget(ghost, ghost_area);
            }
        }

        // Completion overlay
        if matches!(self.editor_mode, InputEditorMode::Inline)
            && self.completion.active
            && !self.completion.candidates.is_empty()
        {
            let total_candidates = self.completion.candidates.len();
            let max_items = 8.min(total_candidates);
            let (scroll_start, scroll_end) = self.completion.visible_window(max_items);
            // +2 for top/bottom border, +1 for count footer
            let overlay_height = max_items as u16 + 3;
            let overlay_width = self
                .completion
                .candidates
                .iter()
                .map(|(cmd, desc)| {
                    let desc_len = if desc.is_empty() { 0 } else { 3 + desc.len() }; // " — desc"
                    cmd.len() + desc_len
                })
                .max()
                .unwrap_or(10) as u16
                + 4; // padding
            let overlay_width = overlay_width.clamp(24, area.width.saturating_sub(2));

            // Position above input area (with 1-row gap from input border)
            let overlay_y = area.y.saturating_sub(overlay_height);
            let overlay_x = area.x + 1;
            let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);

            // Clear area behind overlay
            frame.render_widget(Clear, overlay_area);

            // Count indicator for the overlay title
            let sel_idx = self.completion.selected;
            let count_title = format!(
                " Commands {}/{} ",
                (sel_idx + 1).min(total_candidates),
                total_candidates
            );

            let items: Vec<ListItem> = self
                .completion
                .candidates
                .iter()
                .skip(scroll_start)
                .take(scroll_end.saturating_sub(scroll_start))
                .enumerate()
                .map(|(i, (cmd, desc))| {
                    let candidate_idx = scroll_start + i;
                    let is_selected = candidate_idx == self.completion.selected;
                    let bg = if is_selected {
                        Color::Rgb(55, 55, 75)
                    } else {
                        Color::Reset
                    };
                    let cmd_style = if is_selected {
                        Style::default()
                            .bg(bg)
                            .fg(Color::Cyan)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::Rgb(200, 200, 210))
                    };
                    let desc_style = if is_selected {
                        Style::default().bg(bg).fg(Color::Rgb(140, 145, 165))
                    } else {
                        Style::default().fg(Color::Rgb(95, 100, 120))
                    };
                    let mut spans = vec![
                        selector_marker(is_selected, Color::Cyan, Some(bg)),
                        Span::styled(format!(" {cmd}"), cmd_style),
                    ];
                    if !desc.is_empty() {
                        spans.push(Span::styled(format!(" — {desc}"), desc_style));
                    }
                    ListItem::new(Line::from(spans))
                })
                .collect();

            let footer_line = if total_candidates > max_items {
                let hidden =
                    total_candidates.saturating_sub(scroll_end.saturating_sub(scroll_start));
                format!(
                    " Tab/↑↓ navigate  {} jump  +{} more ",
                    self.paging_key_hint_label(),
                    hidden
                )
            } else {
                " Tab/↑↓ navigate  Enter select  Esc cancel ".to_string()
            };

            // Split area: list body + footer
            let inner_chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Min(1),    // list items
                    Constraint::Length(1), // footer hint
                ])
                .vertical_margin(1)
                .horizontal_margin(0)
                .split(overlay_area);

            let list = List::new(items)
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(Color::Rgb(70, 75, 100)))
                        .title(count_title)
                        .title_style(Style::default().fg(Color::Rgb(140, 145, 165))),
                )
                .style(Style::default().bg(Color::Rgb(25, 25, 35)));
            frame.render_widget(list, overlay_area);

            // Render footer hint inside the border
            let footer_area = inner_chunks[1];
            let footer = Paragraph::new(Span::styled(
                footer_line,
                Style::default().fg(Color::Rgb(80, 85, 110)),
            ))
            .style(Style::default().bg(Color::Rgb(25, 25, 35)));
            frame.render_widget(footer, footer_area);
        }
    }
}

/// Build a compact recap string showing the last few exchanges in a resumed session.
///
/// Mirrors hermes-agent's conversation recap panel:
/// - User messages shown with ● prefix, truncated to 300 chars
/// - Assistant messages shown with ◆ prefix, truncated to 200 chars
/// - Tool calls collapsed into a single line
/// - At most 10 recent exchanges shown, with "...N earlier messages..." indicator
fn build_session_recap(messages: &[edgecrab_types::Message]) -> String {
    use edgecrab_types::Role;

    const MAX_SHOWN: usize = 10;
    const USER_MAX: usize = 300;
    const ASSISTANT_MAX: usize = 200;

    // Filter to user/assistant turns (skip system, tool results)
    let turns: Vec<_> = messages
        .iter()
        .filter(|m| matches!(m.role, Role::User | Role::Assistant))
        .collect();

    if turns.is_empty() {
        return String::new();
    }

    let mut lines = Vec::new();
    lines.push("── Session Recap ──".to_string());

    let skip = turns.len().saturating_sub(MAX_SHOWN);
    if skip > 0 {
        lines.push(format!("  ...{skip} earlier messages..."));
    }

    for msg in turns.iter().skip(skip) {
        let text = msg.text_content();
        if text.is_empty() {
            // Assistant turn with only tool calls
            if let Some(ref tc) = msg.tool_calls {
                let names: Vec<_> = tc.iter().map(|t| t.function.name.as_str()).collect();
                lines.push(format!("  ◆ [tool calls: {}]", names.join(", ")));
            }
            continue;
        }
        let (icon, max_len) = match msg.role {
            Role::User => ("●", USER_MAX),
            Role::Assistant => ("◆", ASSISTANT_MAX),
            _ => continue,
        };
        let truncated = if text.len() > max_len {
            format!(
                "{}…",
                &text[..text
                    .char_indices()
                    .take_while(|(i, _)| *i < max_len)
                    .last()
                    .map(|(i, c)| i + c.len_utf8())
                    .unwrap_or(max_len)]
            )
        } else {
            text
        };
        // Collapse to single line
        let oneline = truncated.lines().collect::<Vec<_>>().join(" ");
        lines.push(format!("  {icon} {oneline}"));
    }

    lines.push("────────────────────".to_string());
    lines.join("\n")
}

/// Format token count for display (e.g. 1234 → "1.2k", 1234567 → "1.2M")
fn format_tokens(count: u64) -> String {
    if count >= 1_000_000 {
        format!("{:.1}M", count as f64 / 1_000_000.0)
    } else if count >= 1_000 {
        format!("{:.1}k", count as f64 / 1_000.0)
    } else {
        format!("{count}")
    }
}

fn truncate_preview(text: &str, max_chars: usize) -> String {
    let trimmed = text.trim();
    if trimmed.chars().count() <= max_chars {
        return trimmed.to_string();
    }
    let shortened: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect();
    format!("{shortened}...")
}

fn clear_terminal_surface(stdout: &mut io::Stdout) -> io::Result<()> {
    crossterm::execute!(
        stdout,
        crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
        crossterm::cursor::MoveTo(0, 0),
    )
}

fn suspend_tui_surface(
    stdout: &mut io::Stdout,
    keyboard_enhancement_enabled: bool,
) -> io::Result<()> {
    let _ = crossterm::terminal::disable_raw_mode();
    if keyboard_enhancement_enabled {
        let _ = crossterm::execute!(stdout, PopKeyboardEnhancementFlags);
    }
    crossterm::execute!(
        stdout,
        crossterm::terminal::LeaveAlternateScreen,
        crossterm::event::DisableMouseCapture,
        crossterm::event::DisableBracketedPaste,
        crossterm::cursor::Show,
    )?;
    clear_terminal_surface(stdout)
}

fn restore_tui_surface(
    stdout: &mut io::Stdout,
    keyboard_enhancement_enabled: bool,
    mouse_capture_enabled: bool,
) -> io::Result<()> {
    crossterm::terminal::enable_raw_mode()?;
    crossterm::execute!(
        stdout,
        crossterm::terminal::EnterAlternateScreen,
        crossterm::event::EnableBracketedPaste,
    )?;
    if keyboard_enhancement_enabled {
        let _ = crossterm::execute!(
            stdout,
            PushKeyboardEnhancementFlags(progressive_keyboard_flags())
        );
    }
    if mouse_capture_enabled {
        let _ = crossterm::execute!(stdout, crossterm::event::EnableMouseCapture);
    }
    Ok(())
}

fn render_terminal_notice_panel(
    stdout: &mut io::Stdout,
    title: &str,
    body: &str,
) -> io::Result<()> {
    clear_terminal_surface(stdout)?;
    writeln!(stdout, "{title}")?;
    writeln!(stdout, "{}", "=".repeat(title.chars().count().max(24)))?;
    writeln!(stdout)?;
    for line in body.lines() {
        writeln!(stdout, "{line}")?;
    }
    stdout.flush()
}

/// Run the interactive TUI event loop.
pub fn run_tui(app: &mut App) -> io::Result<()> {
    // Install a panic hook that restores the terminal before printing the panic.
    // Without this, a crash leaves the terminal in raw mode with no cursor.
    let original_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        // Best-effort terminal restore
        let _ = crossterm::terminal::disable_raw_mode();
        let _ = crossterm::execute!(
            io::stderr(),
            PopKeyboardEnhancementFlags,
            crossterm::terminal::LeaveAlternateScreen,
            crossterm::event::DisableMouseCapture,
            crossterm::event::DisableBracketedPaste,
            crossterm::cursor::Show,
        );
        original_hook(info);
    }));

    crossterm::terminal::enable_raw_mode()?;
    let terminal_caps = terminal_runtime_capabilities();
    let terminal_profile = terminal_caps.ui_profile;
    let compatibility_mode = matches!(terminal_profile, TerminalUiProfile::BasicCompat);
    let reduced_motion_mode = matches!(
        terminal_profile,
        TerminalUiProfile::ReducedMotion | TerminalUiProfile::BasicCompat
    );
    let keyboard_enhancement_supported = if compatibility_mode {
        false
    } else {
        crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false)
    };
    let mut stdout = io::stdout();
    crossterm::execute!(
        stdout,
        crossterm::terminal::EnterAlternateScreen,
        // Mouse capture is ON by default so the scroll wheel moves the output
        // pane.  This intercepts mouse events at the PTY level; native
        // drag-to-copy is unavailable while capture is active (Shift+drag only
        // bypasses it in iTerm2/WezTerm/Kitty, NOT in macOS Terminal.app).
        // Press F6 in the TUI to toggle SELECT mode (capture off = drag=copy).
        crossterm::event::EnableBracketedPaste,
    )?;

    let mut keyboard_enhancement_enabled = false;
    if keyboard_enhancement_supported
        && crossterm::execute!(
            stdout,
            PushKeyboardEnhancementFlags(progressive_keyboard_flags())
        )
        .is_ok()
    {
        keyboard_enhancement_enabled = true;
        // The terminal switches keyboard decoding mode asynchronously after
        // it consumes the CSI-u command. Wait briefly before polling for
        // user input so the first printable key does not race the legacy
        // layout path on non-US keyboards.
        stdout.flush()?;
        std::thread::sleep(KEYBOARD_PROTOCOL_WARMUP);
    }
    app.set_keyboard_enhancement_enabled(keyboard_enhancement_enabled);
    app.terminal_ui_profile = terminal_caps.ui_profile;
    app.terminal_glyph_profile = terminal_caps.glyph_profile;
    app.remote_terminal_session = terminal_caps.remote_session;
    app.direct_paging_keys_supported = terminal_caps.direct_paging_keys_supported;
    app.compact_status_bar = terminal_caps.compact_status_bar;
    app.rich_transcript = terminal_caps.rich_transcript;
    app.show_output_scrollbar = terminal_caps.show_output_scrollbar;
    app.show_ghost_hint = terminal_caps.show_ghost_hint;
    app.min_draw_interval = terminal_caps.min_draw_interval;
    app.animate_status_indicators = terminal_caps.animate_status_indicators;
    app.mouse_capture_enabled = terminal_caps.mouse_capture_enabled;
    if compatibility_mode {
        app.pending_mouse_capture = None;

        // CRITICAL: do NOT propagate streaming=false to the agent.
        //
        // The agent's `config.streaming` controls the LLM API call mode.
        // Many providers (especially Copilot) work reliably ONLY in streaming
        // mode.  When non-streaming is forced, the reqwest client uses a
        // 120-second timeout per attempt with stacked retries, causing the TUI
        // to appear stuck for minutes.
        //
        // Instead, disable ONLY the TUI-side live-token rendering. The agent
        // keeps using streaming APIs, tokens are buffered silently, and the
        // response is flushed at tool boundaries or when AgentResponse::Done
        // arrives. This avoids PTY output flooding while keeping the fast,
        // reliable streaming API path.
        if app.live_token_display_enabled {
            // Disable TUI live display only — do NOT push streaming=false to
            // the agent, which would force the unreliable non-streaming API path.
            app.live_token_display_enabled = false;
            app.push_output(
                "Terminal compatibility mode active. EdgeCrab switched to a low-bandwidth transcript, compact status bar, and buffered live output to avoid PTY stalls. Mouse capture is off, and Ctrl+B / Ctrl+F provide paging. The agent still uses streaming APIs for reliability.",
                OutputRole::System,
            );
        } else {
            app.push_output(
                "Terminal compatibility mode active. EdgeCrab is using a low-bandwidth transcript, compact status bar, and mouse-safe scrolling defaults. Use Ctrl+B / Ctrl+F for paging when the terminal does not deliver dedicated page keys.",
                OutputRole::System,
            );
        }
    } else if reduced_motion_mode {
        app.push_output(
            "Terminal reduced-motion mode active. Redraws are rate-limited and the status bar is compact to keep the TUI responsive, while keyboard support, mouse scrolling, and live output stay enabled.",
            OutputRole::System,
        );
    }
    if app.remote_terminal_session {
        app.push_output(
            "Remote terminal session detected. EdgeCrab is using a calmer draw strategy and simpler glyphs to reduce SSH paint latency and width glitches.",
            OutputRole::System,
        );
    }
    if !keyboard_enhancement_enabled {
        app.push_output(
            format!(
                "Keyboard note: this terminal does not expose Shift+Enter separately. Press Enter to send. Use Ctrl+O to open compose mode and insert a newline, then Ctrl+S to send from compose mode. Ctrl+J still works when the terminal reports it distinctly. {}",
                app.paging_keyboard_note()
            ),
            OutputRole::System,
        );
    }

    // Honour the initial mouse_capture_enabled flag (default: true = SCROLL mode).
    if app.mouse_capture_enabled {
        crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
    };
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = ratatui::Terminal::new(backend)?;

    let result = event_loop(&mut terminal, app);

    crossterm::terminal::disable_raw_mode()?;
    if keyboard_enhancement_enabled {
        crossterm::execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags)?;
    }
    crossterm::execute!(
        terminal.backend_mut(),
        crossterm::terminal::LeaveAlternateScreen,
        crossterm::event::DisableMouseCapture,
        crossterm::event::DisableBracketedPaste,
    )?;
    terminal.show_cursor()?;

    // Print goodbye message to stdout after the TUI is torn down.
    // This appears on the normal terminal after the alternate screen closes.
    println!("{}", app.theme.goodbye_msg);

    result
}

fn event_loop(
    terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
) -> io::Result<()> {
    fn heartbeat_interval_for(profile: TerminalUiProfile) -> Duration {
        match profile {
            TerminalUiProfile::Standard => Duration::from_millis(250),
            TerminalUiProfile::ReducedMotion => Duration::from_millis(800),
            TerminalUiProfile::BasicCompat => Duration::from_millis(1000),
        }
    }

    fn adaptive_draw_interval_after_draw(
        profile: TerminalUiProfile,
        floor: Duration,
        current: Duration,
        draw_cost: Duration,
    ) -> Duration {
        match profile {
            TerminalUiProfile::Standard => Duration::ZERO,
            TerminalUiProfile::ReducedMotion | TerminalUiProfile::BasicCompat => {
                let ceiling = match profile {
                    TerminalUiProfile::ReducedMotion => Duration::from_millis(120),
                    TerminalUiProfile::BasicCompat => Duration::from_millis(180),
                    TerminalUiProfile::Standard => Duration::ZERO,
                };
                let floor_ms = floor.as_millis() as u64;
                let ceiling_ms = ceiling.as_millis() as u64;
                let current_ms = current.as_millis() as u64;
                let draw_ms = draw_cost.as_millis() as u64;
                let scaled_ms = match profile {
                    TerminalUiProfile::ReducedMotion => draw_ms.saturating_mul(3) / 2,
                    TerminalUiProfile::BasicCompat => draw_ms.saturating_mul(2),
                    TerminalUiProfile::Standard => 0,
                };
                let target_ms = scaled_ms.clamp(floor_ms, ceiling_ms);
                if target_ms > current_ms {
                    Duration::from_millis(target_ms)
                } else {
                    Duration::from_millis(current_ms.saturating_sub(10)).max(floor)
                }
            }
        }
    }

    fn draw_app(
        terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>,
        app: &mut App,
        effective_draw_interval: &mut Duration,
        min_draw_interval: Duration,
        last_draw: &mut Instant,
        last_status_heartbeat: &mut Instant,
    ) -> io::Result<()> {
        if app.needs_full_terminal_clear {
            terminal.clear()?;
            app.needs_full_terminal_clear = false;
        }
        let draw_started = Instant::now();
        terminal.draw(|f| app.render(f))?;
        let draw_cost = draw_started.elapsed();
        app.needs_redraw = false;
        *last_draw = Instant::now();
        *last_status_heartbeat = *last_draw;
        *effective_draw_interval = adaptive_draw_interval_after_draw(
            app.terminal_ui_profile,
            min_draw_interval,
            *effective_draw_interval,
            draw_cost,
        );
        Ok(())
    }

    let mut last_tick = Instant::now();
    let tick_rate = if app.animate_status_indicators {
        std::time::Duration::from_millis(80) // spinner animation rate
    } else {
        std::time::Duration::from_millis(250) // housekeeping without redraw churn
    };

    // In compatibility mode (Apple Terminal etc.), throttle draws so we never
    // saturate the PTY output buffer.  Apple Terminal processes escape sequences
    // ~10-50x slower than GPU-accelerated emulators like iTerm2 / kitty / VSCode.
    // If we draw faster than the terminal can consume, `write()` blocks — which
    // starves `check_responses()` and makes the TUI appear stuck.
    let min_draw_interval = app.min_draw_interval;
    let mut effective_draw_interval = min_draw_interval;
    let mut last_draw = Instant::now() - min_draw_interval; // allow immediate first draw
    let mut last_status_heartbeat = Instant::now();

    loop {
        // Check for agent responses first (non-blocking).  In compat mode the
        // draw path can block for tens of milliseconds waiting for the terminal
        // to drain its buffer, so we drain the response channel BEFORE drawing,
        // and again AFTER polling for terminal events, to minimise the window
        // during which an AgentResponse::Done can sit unprocessed.
        app.check_responses();
        app.poll_remote_skill_search();
        app.poll_remote_plugin_search();
        app.poll_remote_mcp_search();
        app.refresh_log_follow_if_due_at(Instant::now());

        // Advance spinner on each tick
        let now_elapsed = last_tick.elapsed();
        if now_elapsed >= tick_rate {
            if app.tick_spinner() {
                app.needs_redraw = true;
            }
            last_tick = Instant::now();
        }

        if !app.animate_status_indicators
            && app.needs_periodic_status_refresh()
            && last_status_heartbeat.elapsed() >= heartbeat_interval_for(app.terminal_ui_profile)
        {
            app.needs_redraw = true;
            last_status_heartbeat = Instant::now();
        }

        // Only redraw when state changed — reduces CPU on idle.
        // In compat mode, honour `min_draw_interval` to avoid flooding the
        // PTY output buffer; `needs_redraw` stays true so we eventually draw
        // once the interval has elapsed.
        let draw_interval_ok = last_draw.elapsed() >= effective_draw_interval;
        if app.needs_redraw && draw_interval_ok {
            // When clear_output() was called, force a full terminal repaint
            // before drawing so that any out-of-band characters that landed on
            // the alternate screen (e.g. tracing output from a background task)
            // are erased. terminal.clear() resets ratatui's internal prev-buffer
            // so the next draw() writes every cell from scratch.
            draw_app(
                terminal,
                app,
                &mut effective_draw_interval,
                min_draw_interval,
                &mut last_draw,
                &mut last_status_heartbeat,
            )?;
        }

        // Poll with a short timeout so we loop quickly while streaming/thinking.
        // In compat mode use a longer timeout to let the terminal catch up.
        let poll_timeout = if effective_draw_interval >= std::time::Duration::from_millis(120) {
            // Slow terminal: keep the loop responsive while avoiding spin.
            std::time::Duration::from_millis(40)
        } else if app.is_processing {
            std::time::Duration::from_millis(16) // ~60fps while streaming
        } else {
            std::time::Duration::from_millis(40) // responsive idle without churn
        };

        let mut priority_interaction = false;
        if event::poll(poll_timeout)? {
            match event::read()? {
                Event::Key(key) => {
                    app.handle_key_event(key);
                    priority_interaction = true;
                }
                Event::Paste(text) => {
                    // Bracketed paste — insert text directly (safe from injection:
                    // bracketed paste prevents escape sequences from being executed)
                    app.handle_paste(text);
                    priority_interaction = true;
                }
                Event::Mouse(mouse) => {
                    app.handle_mouse_event(mouse);
                    priority_interaction = true;
                }
                Event::Resize(_, _) => {
                    // Terminal resized — force redraw and invalidate all render caches
                    app.needs_redraw = true;
                    app.needs_full_terminal_clear = true;
                    for line in &mut app.output {
                        line.invalidate_render_cache();
                    }
                    priority_interaction = true;
                }
                _ => {}
            }
        }

        // Drain responses again after event processing to minimise latency
        // for AgentResponse::Done arriving while we were in poll()/draw().
        app.check_responses();

        // User-directed interactions should feel immediate even on slow PTYs.
        // These redraws are sparse and locally valuable, so bypass the
        // background draw throttle after key/paste/mouse/resize events.
        if priority_interaction && app.needs_redraw {
            draw_app(
                terminal,
                app,
                &mut effective_draw_interval,
                min_draw_interval,
                &mut last_draw,
                &mut last_status_heartbeat,
            )?;
        }

        if let Some(enabled) = app.take_mouse_capture_request() {
            if enabled {
                crossterm::execute!(terminal.backend_mut(), crossterm::event::EnableMouseCapture)?;
            } else {
                crossterm::execute!(
                    terminal.backend_mut(),
                    crossterm::event::DisableMouseCapture
                )?;
            }
        }

        if app.should_exit() {
            if app.voice_recording.is_some() {
                app.abort_voice_recording("Voice recording cancelled during exit.");
            }
            return Ok(());
        }
    }
}

/// Write raw RGBA pixel data as a minimal PNG file.
///
/// Uses a very simple approach: writes uncompressed DEFLATE blocks via
/// the `flate2` or stdlib. Since we don't have an image crate dep,
/// we write a valid PNG manually using zlib-compressed IDAT chunks.
fn write_rgba_png(
    path: &std::path::Path,
    rgba: &[u8],
    width: u32,
    height: u32,
) -> std::io::Result<()> {
    use std::io::Write;

    let mut file = std::fs::File::create(path)?;

    // PNG signature
    file.write_all(&[137, 80, 78, 71, 13, 10, 26, 10])?;

    // Helper to write a PNG chunk
    fn write_chunk(w: &mut impl Write, chunk_type: &[u8; 4], data: &[u8]) -> std::io::Result<()> {
        let len = data.len() as u32;
        w.write_all(&len.to_be_bytes())?;
        w.write_all(chunk_type)?;
        w.write_all(data)?;
        let mut crc_data = Vec::with_capacity(4 + data.len());
        crc_data.extend_from_slice(chunk_type);
        crc_data.extend_from_slice(data);
        let crc = png_crc32(&crc_data);
        w.write_all(&crc.to_be_bytes())?;
        Ok(())
    }

    // IHDR
    let mut ihdr = Vec::with_capacity(13);
    ihdr.extend_from_slice(&width.to_be_bytes());
    ihdr.extend_from_slice(&height.to_be_bytes());
    ihdr.push(8); // bit depth
    ihdr.push(6); // color type: RGBA
    ihdr.push(0); // compression
    ihdr.push(0); // filter
    ihdr.push(0); // interlace
    write_chunk(&mut file, b"IHDR", &ihdr)?;

    // IDAT — build raw image data (filter byte 0 per row + RGBA pixels)
    let row_len = 1 + (width as usize * 4);
    let mut raw = Vec::with_capacity(row_len * height as usize);
    for y in 0..height as usize {
        raw.push(0u8); // filter: None
        let start = y * width as usize * 4;
        let end = start + width as usize * 4;
        if end <= rgba.len() {
            raw.extend_from_slice(&rgba[start..end]);
        } else {
            // Pad with transparent black if data is short
            raw.extend(std::iter::repeat_n(0u8, width as usize * 4));
        }
    }

    // Simple DEFLATE using stored blocks (no compression, but always valid)
    let compressed = deflate_stored(&raw);
    write_chunk(&mut file, b"IDAT", &compressed)?;

    // IEND
    write_chunk(&mut file, b"IEND", &[])?;

    Ok(())
}

/// Minimal stored-only DEFLATE wrapper (zlib format).
fn deflate_stored(data: &[u8]) -> Vec<u8> {
    let mut out = Vec::new();
    // zlib header: CM=8, CINFO=7 (32K window), FCHECK so header%31==0
    out.push(0x78);
    out.push(0x01);

    // Split into 65535-byte stored blocks
    let mut offset = 0;
    while offset < data.len() {
        let remaining = data.len() - offset;
        let block_len = remaining.min(65535);
        let is_final = offset + block_len >= data.len();
        out.push(if is_final { 0x01 } else { 0x00 }); // BFINAL + BTYPE=00 (stored)
        let len = block_len as u16;
        out.extend_from_slice(&len.to_le_bytes());
        out.extend_from_slice(&(!len).to_le_bytes());
        out.extend_from_slice(&data[offset..offset + block_len]);
        offset += block_len;
    }

    // Adler-32 checksum
    let adler = adler32(data);
    out.extend_from_slice(&adler.to_be_bytes());
    out
}

fn adler32(data: &[u8]) -> u32 {
    let mut a: u32 = 1;
    let mut b: u32 = 0;
    for &byte in data {
        a = (a + byte as u32) % 65521;
        b = (b + a) % 65521;
    }
    (b << 16) | a
}

fn png_crc32(data: &[u8]) -> u32 {
    let mut crc: u32 = 0xFFFF_FFFF;
    for &byte in data {
        crc ^= byte as u32;
        for _ in 0..8 {
            if crc & 1 != 0 {
                crc = (crc >> 1) ^ 0xEDB8_8320;
            } else {
                crc >>= 1;
            }
        }
    }
    !crc
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line_spans_text(line: &OutputLine) -> String {
        line.prebuilt_spans
            .as_ref()
            .map(|spans| {
                spans
                    .iter()
                    .map(|span| span.content.as_ref())
                    .collect::<String>()
            })
            .unwrap_or_else(|| line.text.clone())
    }

    fn plain_line_text(line: &Line<'_>) -> String {
        line.spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>()
    }

    fn plain_lines_text(lines: &[Line<'_>]) -> String {
        lines
            .iter()
            .map(plain_line_text)
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn terminal_ui_profile_detects_basic_compat_for_apple_terminal() {
        assert_eq!(
            terminal_ui_profile_for(
                Some("Apple_Terminal"),
                Some("xterm-256color"),
                false,
                false,
                false,
            ),
            TerminalUiProfile::BasicCompat
        );
    }

    #[test]
    fn terminal_ui_profile_detects_reduced_motion_for_linux_desktop_terminals() {
        assert_eq!(
            terminal_ui_profile_for(
                Some("gnome-terminal-server"),
                Some("xterm-256color"),
                false,
                false,
                false,
            ),
            TerminalUiProfile::ReducedMotion
        );
    }

    #[test]
    fn terminal_ui_profile_keeps_fast_terminals_on_standard_mode() {
        assert_eq!(
            terminal_ui_profile_for(
                Some("iTerm.app"),
                Some("xterm-256color"),
                false,
                false,
                false,
            ),
            TerminalUiProfile::Standard
        );
        assert_eq!(
            terminal_ui_profile_for(Some("WezTerm"), Some("xterm-256color"), false, false, false,),
            TerminalUiProfile::Standard
        );
    }

    #[test]
    fn terminal_ui_profile_detects_reduced_motion_for_multiplexed_terms() {
        assert_eq!(
            terminal_ui_profile_for(None, Some("screen-256color"), false, false, false),
            TerminalUiProfile::ReducedMotion
        );
        assert_eq!(
            terminal_ui_profile_for(None, Some("tmux-256color"), false, false, false),
            TerminalUiProfile::ReducedMotion
        );
    }

    #[test]
    fn remote_sessions_step_standard_profile_down_to_reduced_motion() {
        assert_eq!(
            adjust_ui_profile_for_remote(TerminalUiProfile::Standard, true),
            TerminalUiProfile::ReducedMotion
        );
        assert_eq!(
            adjust_ui_profile_for_remote(TerminalUiProfile::BasicCompat, true),
            TerminalUiProfile::BasicCompat
        );
    }

    #[test]
    fn remote_or_compat_sessions_prefer_ascii_glyphs() {
        assert_eq!(
            terminal_glyph_profile_for(
                TerminalUiProfile::ReducedMotion,
                true,
                Some("xterm-256color")
            ),
            TerminalGlyphProfile::Ascii
        );
        assert_eq!(
            terminal_glyph_profile_for(
                TerminalUiProfile::BasicCompat,
                false,
                Some("xterm-256color")
            ),
            TerminalGlyphProfile::Ascii
        );
        assert_eq!(
            terminal_glyph_profile_for(TerminalUiProfile::Standard, false, Some("xterm-256color")),
            TerminalGlyphProfile::Unicode
        );
    }

    #[test]
    fn direct_paging_keys_are_disabled_for_apple_terminal_and_remote_sessions() {
        assert!(!terminal_supports_direct_paging_keys(
            Some("Apple_Terminal"),
            Some("xterm-256color"),
            TerminalUiProfile::BasicCompat,
            false,
        ));
        assert!(!terminal_supports_direct_paging_keys(
            Some("iTerm.app"),
            Some("xterm-256color"),
            TerminalUiProfile::Standard,
            true,
        ));
    }

    #[test]
    fn direct_paging_keys_remain_enabled_for_fast_local_terminals() {
        assert!(terminal_supports_direct_paging_keys(
            Some("WezTerm"),
            Some("xterm-256color"),
            TerminalUiProfile::Standard,
            false,
        ));
        assert!(terminal_supports_direct_paging_keys(
            Some("gnome-terminal-server"),
            Some("xterm-256color"),
            TerminalUiProfile::ReducedMotion,
            false,
        ));
    }

    fn mock_agent() -> Arc<edgecrab_core::Agent> {
        let provider: Arc<dyn edgequake_llm::LLMProvider> =
            Arc::new(edgequake_llm::MockProvider::new());
        Arc::new(
            edgecrab_core::AgentBuilder::new("mock")
                .provider(provider)
                .tools(Arc::new(edgecrab_tools::registry::ToolRegistry::new()))
                .build()
                .expect("build agent"),
        )
    }

    fn mock_agent_with_state_db(db: Arc<edgecrab_state::SessionDb>) -> Arc<edgecrab_core::Agent> {
        let provider: Arc<dyn edgequake_llm::LLMProvider> =
            Arc::new(edgequake_llm::MockProvider::new());
        Arc::new(
            edgecrab_core::AgentBuilder::new("mock")
                .provider(provider)
                .tools(Arc::new(edgecrab_tools::registry::ToolRegistry::new()))
                .state_db(db)
                .build()
                .expect("build agent"),
        )
    }

    fn set_test_edgecrab_home(path: &std::path::Path) {
        crate::profile::set_test_edgecrab_home_override(Some(path));
        unsafe {
            std::env::set_var("EDGECRAB_HOME", path);
        }
    }

    fn clear_test_edgecrab_home() {
        crate::profile::set_test_edgecrab_home_override(None);
        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    fn write_script_plugin(home: &std::path::Path, plugin_name: &str, tool_name: &str) {
        let plugin_dir = home.join("plugins").join(plugin_name);
        std::fs::create_dir_all(&plugin_dir).expect("plugin dir");
        std::fs::write(
            plugin_dir.join("plugin.toml"),
            format!(
                r#"[plugin]
name = "{plugin_name}"
version = "1.0.0"
description = "Demo script plugin"
kind = "script"

[script]
file = "main.rhai"

[[tools]]
name = "{tool_name}"
description = "Demo plugin tool"
"#
            ),
        )
        .expect("plugin manifest");
        std::fs::write(
            plugin_dir.join("main.rhai"),
            format!(
                r#"fn tool_call(name, args_json) {{
    if name == "{tool_name}" {{
        return "{{\"ok\":true}}";
    }}
    return "{{}}";
}}
"#
            ),
        )
        .expect("script file");
    }

    fn sample_browser_session(
        id: &str,
        title: &str,
        started_at: f64,
        model: &str,
    ) -> edgecrab_state::SessionRecord {
        edgecrab_state::SessionRecord {
            id: id.into(),
            source: "cli".into(),
            user_id: None,
            model: Some(model.into()),
            system_prompt: None,
            parent_session_id: None,
            started_at,
            ended_at: None,
            end_reason: None,
            message_count: 0,
            tool_call_count: 0,
            input_tokens: 0,
            output_tokens: 0,
            cache_read_tokens: 0,
            cache_write_tokens: 0,
            reasoning_tokens: 0,
            estimated_cost_usd: None,
            title: Some(title.into()),
        }
    }

    #[tokio::test]
    async fn app_init() {
        let app = App::new();
        assert!(!app.should_exit());
        assert!(app.output.is_empty());
    }

    #[tokio::test]
    async fn app_push_output() {
        let mut app = App::new();
        app.push_output("hello", OutputRole::Assistant);
        assert_eq!(app.output.len(), 1);
        assert_eq!(app.output[0].text, "hello");
    }

    #[tokio::test]
    async fn app_clear_output() {
        let mut app = App::new();
        app.push_output("line1", OutputRole::System);
        app.push_output("line2", OutputRole::System);
        app.clear_output();
        assert!(app.output.is_empty());
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn gateway_platform_panel_surfaces_runtime_states_and_actions() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
            std::env::set_var("TELEGRAM_BOT_TOKEN", "telegram-token");
            std::env::set_var("DISCORD_BOT_TOKEN", "discord-token");
        }

        let mut config = edgecrab_core::AppConfig::default();
        config.gateway.enable_platform("telegram");
        config.gateway.telegram.home_channel = Some("ops-room".into());
        config.gateway.whatsapp.enabled = true;
        config.gateway.whatsapp.session_path = Some(dir.path().join("whatsapp-session"));

        let diagnostics = collect_platform_diagnostics(&config);
        let panel = crate::gateway_presentation::render_gateway_platforms_panel(
            &config,
            &diagnostics,
            Some(&crate::gateway_cmd::GatewayStatus {
                pid: Some(42),
                running: true,
                stale_pid: false,
                log_path: dir.path().join("logs").join("gateway.log"),
            }),
            &[(
                "telegram".into(),
                "XKGH5N7P".into(),
                "123456".into(),
                "alice".into(),
                12,
            )],
            &[("telegram".into(), "123456".into(), "alice".into())],
        );

        assert!(panel.contains("Runtime"));
        assert!(panel.contains("Process: running (pid 42)"));
        assert!(panel.contains("Gateway homes:"));
        assert!(panel.contains("telegram  ops-room"));
        assert!(panel.contains("Pairing"));
        assert!(panel.contains("Pending DM approvals: 1"));
        assert!(panel.contains("Ready now"));
        assert!(panel.contains("Telegram"));
        assert!(panel.contains("live edits + typing keepalive"));
        assert!(panel.contains("Needs attention"));
        assert!(panel.contains("WhatsApp"));
        assert!(panel.contains("QR pairing"));
        assert!(panel.contains("Ready to enable"));
        assert!(panel.contains("Discord"));
        assert!(panel.contains("Not configured yet"));
        assert!(panel.contains("edgecrab gateway configure"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
            std::env::remove_var("TELEGRAM_BOT_TOKEN");
            std::env::remove_var("DISCORD_BOT_TOKEN");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn platforms_command_opens_gateway_browser_overlay() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
            std::env::set_var("TELEGRAM_BOT_TOKEN", "telegram-token");
        }

        let mut app = App::new();
        app.handle_show_platforms();

        assert!(app.gateway_browser.active);
        let current = app
            .gateway_browser
            .current()
            .expect("selected gateway entry");
        assert!(current.detail_view.contains("Enter  primary setup field"));
        assert!(
            current
                .detail_view
                .contains("Space  enable or disable this platform")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
            std::env::remove_var("TELEGRAM_BOT_TOKEN");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn gateway_bind_editor_persists_from_tui_flow() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let mut app = App::new();
        app.apply_value_capture_action(ValueCaptureAction::BindAddress, "0.0.0.0:9091".into());

        let config = edgecrab_core::AppConfig::load_from(&dir.path().join("config.yaml"))
            .expect("load config");
        assert_eq!(config.gateway.host, "0.0.0.0");
        assert_eq!(config.gateway.port, 9091);

        clear_test_edgecrab_home();
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn profile_create_capture_creates_profile_inline() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        let edgecrab_home = dir.path().join(".edgecrab");
        set_test_edgecrab_home(&edgecrab_home);

        let manager = ProfileManager::new();
        manager
            .create_report("cargo", false, false, None)
            .expect("create source");
        let source_home = crate::profile::edgecrab_home()
            .join("profiles")
            .join("cargo");
        std::fs::write(source_home.join("SOUL.md"), "# Source profile\n")
            .expect("write source soul");

        let mut app = App::new();
        set_test_edgecrab_home(&edgecrab_home);
        app.apply_value_capture_action(
            ValueCaptureAction::ProfileCreate,
            "git --clone --clone-from cargo".into(),
        );

        set_test_edgecrab_home(&edgecrab_home);
        let work_home = crate::profile::edgecrab_home().join("profiles").join("git");
        assert!(work_home.exists());
        assert_eq!(
            std::fs::read_to_string(work_home.join("SOUL.md")).expect("read work soul"),
            "# Source profile\n"
        );

        clear_test_edgecrab_home();
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn profile_delete_capture_requires_exact_confirmation() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        let edgecrab_home = dir.path().join(".edgecrab");
        set_test_edgecrab_home(&edgecrab_home);

        ProfileManager::new()
            .create_report("git", false, false, None)
            .expect("create work");
        let work_home = crate::profile::edgecrab_home().join("profiles").join("git");

        let mut app = App::new();
        set_test_edgecrab_home(&edgecrab_home);
        app.apply_value_capture_action(
            ValueCaptureAction::ProfileDeleteConfirm("git".into()),
            "wrong".into(),
        );
        assert!(work_home.exists());

        set_test_edgecrab_home(&edgecrab_home);
        app.apply_value_capture_action(
            ValueCaptureAction::ProfileDeleteConfirm("git".into()),
            "git".into(),
        );
        set_test_edgecrab_home(&edgecrab_home);
        assert!(
            !work_home.exists(),
            "profile delete output:\n{}\nactive_profile={}\nwork_home={}",
            app.output
                .iter()
                .map(|line| line.text.as_str())
                .collect::<Vec<_>>()
                .join("\n"),
            edgecrab_home.join(".active_profile").display(),
            work_home.display(),
        );

        clear_test_edgecrab_home();
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn profile_rename_capture_renames_profile_inline() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        let edgecrab_home = dir.path().join(".edgecrab");
        set_test_edgecrab_home(&edgecrab_home);

        ProfileManager::new()
            .create_report("tar", false, false, None)
            .expect("create work");

        let mut app = App::new();
        set_test_edgecrab_home(&edgecrab_home);
        app.apply_value_capture_action(
            ValueCaptureAction::ProfileRename("tar".into()),
            "sed".into(),
        );

        set_test_edgecrab_home(&edgecrab_home);
        assert!(
            !crate::profile::edgecrab_home()
                .join("profiles")
                .join("tar")
                .exists()
        );
        assert!(
            crate::profile::edgecrab_home()
                .join("profiles")
                .join("sed")
                .exists()
        );

        clear_test_edgecrab_home();
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn profile_export_import_capture_round_trips_archive() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        let edgecrab_home = dir.path().join(".edgecrab");
        set_test_edgecrab_home(&edgecrab_home);

        ProfileManager::new()
            .create_report("git", false, false, None)
            .expect("create work");
        let work_home = crate::profile::edgecrab_home().join("profiles").join("git");
        std::fs::write(work_home.join("SOUL.md"), "# Exported profile\n").expect("write soul");

        let archive = dir.path().join("git-export.tar.gz");
        let mut app = App::new();
        set_test_edgecrab_home(&edgecrab_home);
        app.apply_value_capture_action(
            ValueCaptureAction::ProfileExport("git".into()),
            archive.display().to_string(),
        );
        assert!(archive.exists());

        set_test_edgecrab_home(&edgecrab_home);
        app.apply_value_capture_action(
            ValueCaptureAction::ProfileImport,
            format!("{} sed", archive.display()),
        );

        set_test_edgecrab_home(&edgecrab_home);
        let imported_home = crate::profile::edgecrab_home().join("profiles").join("sed");
        assert!(imported_home.exists());
        assert_eq!(
            std::fs::read_to_string(imported_home.join("SOUL.md")).expect("read imported soul"),
            "# Exported profile\n"
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn profile_command_shows_active_profile_status() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path().join(".edgecrab"));
        }

        let manager = ProfileManager::new();
        manager
            .create_report("git", false, false, None)
            .expect("create profile");
        manager.set_active_profile("git").expect("set active");

        let mut app = App::new();
        app.handle_show_profiles(String::new());

        let rendered = app.output.last().expect("output").text.clone();
        assert!(rendered.starts_with("Profile: git\nHome:    "));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn profile_config_subcommand_opens_overlay_in_config_mode() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path().join(".edgecrab"));
        }

        ProfileManager::new()
            .create_report("git", false, false, None)
            .expect("create profile");

        let mut app = App::new();
        app.handle_show_profiles("config git".into());

        assert!(app.profile_selector.active);
        assert_eq!(app.profile_detail_mode, ProfileDetailMode::Config);
        assert_eq!(
            app.profile_selector
                .current()
                .map(|entry| entry.name.as_str()),
            Some("git")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn status_command_opens_document_overlay() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path().join(".edgecrab"));
        }
        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let agent = mock_agent();
        let mut app = App::new();
        app.set_agent(agent);

        app.handle_show_status();

        let overlay = app.document_overlay.as_ref().expect("document overlay");
        assert_eq!(overlay.title, "Session Status");
        assert!(overlay.body.contains("Session status:"));
        assert!(overlay.body.contains("Model:"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn prompt_command_opens_document_overlay_when_custom_prompt_missing() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path().join(".edgecrab"));
        }
        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let agent = mock_agent();
        let mut app = App::new();
        app.set_agent(agent);

        app.handle_show_prompt();

        let overlay = app.document_overlay.as_ref().expect("document overlay");
        assert_eq!(overlay.title, "System Prompt");
        assert!(overlay.subtitle.contains("default assembled"));
        assert!(overlay.body.contains("No custom prompt set"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn prompt_command_persists_custom_prompt_override() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path().join(".edgecrab"));
        }
        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let agent = mock_agent();
        let mut app = App::new();
        app.set_agent(agent.clone());

        app.handle_prompt_command("Answer like a systems engineer.".into());

        let config = edgecrab_core::AppConfig::load().expect("load config");
        assert_eq!(
            config.agent.system_prompt,
            "Answer like a systems engineer."
        );
        assert_eq!(
            rt.block_on(agent.custom_system_prompt()),
            Some("Answer like a systems engineer.".into())
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn document_overlay_keyboard_navigation_is_functional() {
        let mut app = App::new();
        app.open_report_overlay(
            "Diagnostics",
            "Keyboard navigation test",
            (0..40)
                .map(|idx| format!("line {idx:02}"))
                .collect::<Vec<_>>()
                .join("\n"),
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(
            app.document_overlay.as_ref().map(|overlay| overlay.scroll),
            Some(8)
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
        assert_eq!(
            app.document_overlay.as_ref().map(|overlay| overlay.scroll),
            Some(0)
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
        assert_eq!(
            app.document_overlay.as_ref().map(|overlay| overlay.scroll),
            Some(u16::MAX)
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert!(app.document_overlay.is_none());
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn skills_list_opens_document_overlay() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path().join(".edgecrab");
        let skills_dir = home.join("skills");
        std::fs::create_dir_all(&skills_dir).expect("skills dir");
        std::fs::write(skills_dir.join("demo.md"), "# Demo Skill\n").expect("skill file");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", &home);
        }

        let mut app = App::new();
        app.handle_show_skills("list".into());

        let overlay = app.document_overlay.as_ref().expect("document overlay");
        assert_eq!(overlay.title, "Installed Skills");
        assert!(overlay.body.contains("demo.md"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    #[ignore] // Skip - fragile due to global state pollution
    fn stream_command_updates_runtime_behavior_and_config() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let provider = Arc::new(edgequake_llm::MockProvider::new());
        rt.block_on(provider.add_response("A".repeat(160)));
        let provider_dyn: Arc<dyn edgequake_llm::LLMProvider> = provider.clone();
        let agent = Arc::new(
            edgecrab_core::AgentBuilder::new("mock")
                .provider(provider_dyn)
                .tools(Arc::new(edgecrab_tools::registry::ToolRegistry::new()))
                .build()
                .expect("build agent"),
        );
        let mut app = App::new();
        drop(_enter);
        app.set_agent(agent.clone());

        app.handle_set_streaming("off".into());
        assert!(!app.streaming_enabled);
        assert!(
            app.output
                .last()
                .is_some_and(|line| line.text.contains("Streaming: OFF"))
        );

        let mut off_token_count = 0;
        let (off_tx, mut off_rx) = tokio::sync::mpsc::unbounded_channel();
        rt.block_on(agent.chat_streaming("hello", off_tx))
            .expect("stream off");
        rt.block_on(async {
            while let Some(event) = off_rx.recv().await {
                if matches!(event, edgecrab_core::StreamEvent::Token(_)) {
                    off_token_count += 1;
                }
            }
        });
        assert_eq!(off_token_count, 1);

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(!cfg.display.streaming);
        assert!(!cfg.model.streaming);

        rt.block_on(provider.add_response("B".repeat(160)));
        app.handle_set_streaming("on".into());
        assert!(app.streaming_enabled);
        assert!(
            app.output
                .last()
                .is_some_and(|line| line.text.contains("Streaming: ON"))
        );

        let mut on_token_count = 0;
        let (on_tx, mut on_rx) = tokio::sync::mpsc::unbounded_channel();
        rt.block_on(agent.chat_streaming("hello again", on_tx))
            .expect("stream on");
        rt.block_on(async {
            while let Some(event) = on_rx.recv().await {
                if matches!(event, edgecrab_core::StreamEvent::Token(_)) {
                    on_token_count += 1;
                }
            }
        });
        assert!(on_token_count > 1);

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.display.streaming);
        assert!(cfg.model.streaming);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[ignore] // Skip - fragile due to runtime/session state pollution
    async fn background_prompt_uses_isolated_session() {
        let provider: Arc<dyn edgequake_llm::LLMProvider> =
            Arc::new(edgequake_llm::MockProvider::new());
        let agent = Arc::new(
            edgecrab_core::AgentBuilder::new("mock")
                .provider(provider)
                .build()
                .expect("build agent"),
        );

        let _ = agent
            .chat("foreground turn")
            .await
            .expect("foreground chat");
        let before = agent.session_snapshot().await;

        let mut app = App::new();
        app.set_agent(agent.clone());
        app.handle_background_prompt("background turn".into());

        tokio::time::sleep(Duration::from_millis(25)).await;
        app.check_responses();

        let after = agent.session_snapshot().await;
        assert_eq!(after.message_count, before.message_count);
        assert!(
            app.output
                .iter()
                .any(|line| line.text.contains("Background task #1 started"))
        );
        assert!(
            app.output
                .iter()
                .any(|line| line.text.contains("EdgeCrab (background #1)"))
        );
        assert!(app.background_tasks_active.is_empty());
    }

    #[tokio::test]
    #[ignore] // Skip - fragile due to conversation history state pollution
    async fn btw_works_without_existing_conversation_history() {
        let provider = Arc::new(edgequake_llm::MockProvider::new());
        provider
            .add_response("Short side-answer from isolated context.")
            .await;
        let provider_dyn: Arc<dyn edgequake_llm::LLMProvider> = provider.clone();
        let agent = Arc::new(
            edgecrab_core::AgentBuilder::new("mock")
                .provider(provider_dyn)
                .tools(Arc::new(edgecrab_tools::registry::ToolRegistry::new()))
                .build()
                .expect("build agent"),
        );

        let mut app = App::new();
        app.set_agent(agent.clone());

        app.handle_side_question("what owns title sanitization?".into());
        tokio::time::sleep(Duration::from_millis(25)).await;
        app.check_responses();

        assert!(
            app.output
                .iter()
                .any(|line| { line.text.contains("💬 /btw what owns title sanitization?") })
        );
        assert!(app.output.iter().any(|line| {
            line.text.contains("/btw what owns title sanitization?")
                && line
                    .text
                    .contains("Short side-answer from isolated context.")
        }));

        let snapshot = agent.session_snapshot().await;
        assert_eq!(snapshot.message_count, 0);
    }

    #[test]
    fn background_progress_text_formats_tool_events() {
        let event = edgecrab_core::StreamEvent::ToolExec {
            tool_call_id: "call_1".into(),
            name: "terminal".into(),
            args_json: r#"{"command":"cargo test -p edgecrab-core"}"#.into(),
        };
        let text = background_progress_text(2, &event).expect("progress text");
        assert!(text.contains("bg#2"));
        assert!(text.contains("$"));
        assert!(text.contains("cargo test -p edgecrab-core"));
    }

    #[test]
    fn background_progress_text_formats_subagent_completion() {
        let event = edgecrab_core::StreamEvent::SubAgentFinish {
            task_index: 1,
            task_count: 3,
            status: "completed".into(),
            duration_ms: 2500,
            summary: "done".into(),
            api_calls: 2,
            model: Some("mock/model".into()),
        };
        let text = background_progress_text(4, &event).expect("progress text");
        assert!(text.contains("bg#4"));
        assert!(text.contains("[2/3]"));
        assert!(text.contains("completed in 2.5s"));
    }

    #[test]
    fn background_status_summary_prefers_latest_progress() {
        let mut tasks = std::collections::HashMap::new();
        tasks.insert(
            "bg-1".into(),
            BackgroundTaskStatus {
                preview: "first task".into(),
                last_progress: Some("↳ bg#1 terminal: cargo test".into()),
                last_seq: 1,
            },
        );
        tasks.insert(
            "bg-2".into(),
            BackgroundTaskStatus {
                preview: "second task".into(),
                last_progress: Some("↳ bg#2 [1/2] terminal: rg delegate".into()),
                last_seq: 2,
            },
        );

        let summary = format_background_status_summary(&tasks).expect("summary");
        assert!(summary.contains("bg#2"));
        assert!(summary.contains("delegate"));
    }

    #[test]
    fn subagent_status_summary_prefers_latest_tool_detail() {
        let mut tasks = std::collections::HashMap::new();
        tasks.insert(
            0,
            ActiveSubagentStatus {
                task_index: 0,
                task_count: 3,
                goal: "inspect repo".into(),
                last_detail: Some("terminal  command: cargo test".into()),
                last_seq: 4,
            },
        );
        tasks.insert(
            1,
            ActiveSubagentStatus {
                task_index: 1,
                task_count: 3,
                goal: "audit gateway".into(),
                last_detail: Some("file_search  query: delegate_task".into()),
                last_seq: 7,
            },
        );

        let summary = format_subagent_status_summary(&tasks).expect("summary");
        assert!(summary.contains("[2/3]"));
        assert!(summary.contains("delegate_task"));
    }

    #[test]
    fn active_tool_status_summary_prefers_latest_detail_and_parallel_count() {
        let mut tools = std::collections::HashMap::new();
        tools.insert(
            "call_1".into(),
            ActiveToolStatus {
                name: "read_file".into(),
                args_json: r#"{"path":"src/lib.rs"}"#.into(),
                last_detail: None,
                started_at: Instant::now(),
                last_seq: 1,
            },
        );
        tools.insert(
            "call_2".into(),
            ActiveToolStatus {
                name: "manage_todo_list".into(),
                args_json:
                    r#"{"items":[{"id":1,"title":"Audit","status":"in-progress"}],"merge":true}"#
                        .into(),
                last_detail: Some("1/3 completed; updating remaining tasks".into()),
                started_at: Instant::now(),
                last_seq: 2,
            },
        );

        let summary = summarize_active_tools(&tools).expect("summary");
        assert_eq!(summary.verb, "running");
        assert_eq!(summary.icon, "☑");
        assert!(
            summary.detail.contains("1/3 completed"),
            "got: {}",
            summary.detail
        );
        assert!(
            summary.detail.contains("+1 more"),
            "got: {}",
            summary.detail
        );
    }

    #[test]
    fn background_progress_text_formats_subagent_reasoning() {
        let event = edgecrab_core::StreamEvent::SubAgentReasoning {
            task_index: 0,
            task_count: 2,
            text: "searching the workspace for delegation regressions".into(),
        };
        let text = background_progress_text(3, &event).expect("progress text");
        assert!(text.contains("bg#3"));
        assert!(text.contains("[1/2]"));
        assert!(text.contains("thinking"));
    }

    #[test]
    fn context_usage_ratio_clamps_at_one_hundred_percent() {
        assert_eq!(context_usage_ratio(210_000, Some(200_000)), Some(1.0));
        assert_eq!(context_usage_ratio(100_000, Some(200_000)), Some(0.5));
        assert_eq!(context_usage_ratio(100, Some(0)), None);
        assert_eq!(context_usage_ratio(100, None), None);
    }

    #[tokio::test]
    async fn app_slash_command_exit() {
        let mut app = App::new();
        app.process_input("/quit");
        assert!(app.should_exit());
    }

    #[tokio::test]
    async fn app_slash_command_help() {
        let mut app = App::new();
        app.process_input("/help");
        assert!(!app.output.is_empty());
        assert!(
            app.output
                .last()
                .is_some_and(|l| l.text.contains("EdgeCrab")
                    || l.text.contains("slash commands")
                    || l.text.contains("Navigation"))
        );
    }

    #[tokio::test]
    async fn image_model_selector_opens_with_default_reset_and_inventory() {
        let mut app = App::new();

        app.open_image_model_selector();

        assert!(app.image_model_selector.active);
        assert_eq!(
            app.image_model_selector
                .items
                .first()
                .map(|entry| entry.display.as_str()),
            Some("default")
        );
        assert!(
            app.image_model_selector
                .items
                .iter()
                .any(|entry| entry.display == "gemini/gemini-2.5-flash-image")
        );
    }

    #[tokio::test]
    async fn model_selector_page_down_pages_list_in_split_view() {
        let mut app = App::new();
        app.open_model_selector_for("anthropic/claude-opus-4.6", ModelSelectorTarget::Primary);
        app.model_selector.selected = 0;
        app.set_split_detail_scroll(DetailSurface::ModelSelector, 0);

        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        let expected = 8.min(app.model_selector.filtered.len().saturating_sub(1));
        assert_eq!(app.model_selector.selected, expected);
        assert_eq!(app.split_detail_scroll(DetailSurface::ModelSelector), 0);
        assert_eq!(
            app.detail_fullscreen_scroll(DetailSurface::ModelSelector),
            0,
            "split-view paging should move the visible list, not invisible detail scroll"
        );
    }

    #[tokio::test]
    #[ignore] // Skip fragile UI test - internal split pane state not properly initialized
    async fn model_selector_tab_focuses_detail_before_paging() {
        let mut app = App::new();
        app.model_selector_seeded = false; // Force fresh seed
        app.model_selector.items.clear(); // Clear any pre-existing items
        app.open_model_selector_for("anthropic/claude-opus-4.6", ModelSelectorTarget::Primary);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert_eq!(
            app.simple_split_focus(DetailSurface::ModelSelector),
            SplitPaneFocus::Detail
        );
        assert_eq!(app.model_selector.selected, 0);
        // After PageDown in detail pane, scroll should increase (7-8 depending on view calculations)
        assert!(app.split_detail_scroll(DetailSurface::ModelSelector) >= 7);
    }

    #[tokio::test]
    async fn raw_ctrl_f_and_ctrl_b_page_model_selector_list_without_page_keys() {
        let mut app = App::new();
        app.open_model_selector_for("anthropic/claude-opus-4.6", ModelSelectorTarget::Primary);
        app.model_selector.selected = 0;

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('\u{6}'),
            KeyModifiers::NONE,
        ));
        let expected = 8.min(app.model_selector.filtered.len().saturating_sub(1));
        assert_eq!(app.model_selector.selected, expected);
        assert_eq!(app.split_detail_scroll(DetailSurface::ModelSelector), 0);

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('\u{2}'),
            KeyModifiers::NONE,
        ));
        assert_eq!(app.model_selector.selected, 0);
        assert_eq!(app.split_detail_scroll(DetailSurface::ModelSelector), 0);
    }

    #[tokio::test]
    async fn model_selector_fullscreen_esc_returns_to_split_view() {
        let mut app = App::new();
        app.open_model_selector_for("anthropic/claude-opus-4.6", ModelSelectorTarget::Primary);
        let selected_before = app.model_selector.selected;

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert!(app.model_selector.active);
        assert!(app.detail_fullscreen_active(DetailSurface::ModelSelector));
        assert_eq!(app.model_selector.selected, selected_before);
        assert_eq!(
            app.detail_fullscreen_scroll(DetailSurface::ModelSelector),
            8
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert!(app.model_selector.active);
        assert!(!app.detail_fullscreen_active(DetailSurface::ModelSelector));
    }

    #[tokio::test]
    #[ignore] // Skip fragile UI test - runtime context pollution, internal state not properly initialized
    async fn tool_manager_tab_focuses_detail_and_arrow_keys_change_scope() {
        let mut app = App::new();
        app.set_agent(mock_agent());
        app.open_tool_manager(ToolManagerMode::All);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        assert_eq!(app.tool_manager_scope, ToolManagerScope::Toolsets);
        assert_eq!(
            app.simple_split_focus(DetailSurface::ToolManager),
            SplitPaneFocus::List
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(
            app.simple_split_focus(DetailSurface::ToolManager),
            SplitPaneFocus::Detail
        );
        assert_eq!(app.split_detail_scroll(DetailSurface::ToolManager), 8);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        assert_eq!(app.tool_manager_scope, ToolManagerScope::All);
        assert_eq!(
            app.simple_split_focus(DetailSurface::ToolManager),
            SplitPaneFocus::List
        );
        assert_eq!(app.split_detail_scroll(DetailSurface::ToolManager), 0);
    }

    #[tokio::test]
    async fn plugin_toggle_tab_focuses_detail_and_arrow_keys_change_scope() {
        let mut app = App::new();
        app.plugin_toggle.set_items(vec![PluginToggleEntry {
            name: "demo".into(),
            display_name: "Demo Plugin".into(),
            description: (0..24)
                .map(|idx| format!("line {idx:02} for plugin detail paging"))
                .collect::<Vec<_>>()
                .join("\n"),
            version: "1.0.0".into(),
            source: "local".into(),
            kind: "skill".into(),
            tool_count: 1,
            estimated_tokens: 64,
            check_state: PluginCheckState::On,
            original_check_state: PluginCheckState::On,
            runtime_status: "enabled".into(),
            trust_level: "trusted".into(),
            tools: vec!["demo_tool".into()],
            cli_commands: vec![],
            missing_env: vec![],
            related_skills: vec![],
            compatibility: Some("cli".into()),
            install_source: None,
            credentials_satisfied: true,
            search_text: "demo plugin skill enabled trusted".into(),
        }]);
        app.plugin_toggle_scope = PluginScope::Global;
        app.plugin_toggle.activate();

        app.handle_key_event(event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        assert!(matches!(app.plugin_toggle_scope, PluginScope::Platform(_)));
        assert_eq!(
            app.simple_split_focus(DetailSurface::PluginToggle),
            SplitPaneFocus::List
        );

        app.plugin_toggle_scope = PluginScope::Global;
        app.plugin_toggle.set_items(vec![PluginToggleEntry {
            name: "demo".into(),
            display_name: "Demo Plugin".into(),
            description: (0..24)
                .map(|idx| format!("line {idx:02} for plugin detail paging"))
                .collect::<Vec<_>>()
                .join("\n"),
            version: "1.0.0".into(),
            source: "local".into(),
            kind: "skill".into(),
            tool_count: 1,
            estimated_tokens: 64,
            check_state: PluginCheckState::On,
            original_check_state: PluginCheckState::On,
            runtime_status: "enabled".into(),
            trust_level: "trusted".into(),
            tools: vec!["demo_tool".into()],
            cli_commands: vec![],
            missing_env: vec![],
            related_skills: vec![],
            compatibility: Some("cli".into()),
            install_source: None,
            credentials_satisfied: true,
            search_text: "demo plugin skill enabled trusted".into(),
        }]);
        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(
            app.simple_split_focus(DetailSurface::PluginToggle),
            SplitPaneFocus::Detail
        );
        assert_eq!(app.split_detail_scroll(DetailSurface::PluginToggle), 8);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        assert!(matches!(app.plugin_toggle_scope, PluginScope::Platform(_)));
        assert_eq!(
            app.simple_split_focus(DetailSurface::PluginToggle),
            SplitPaneFocus::List
        );
        assert_eq!(app.split_detail_scroll(DetailSurface::PluginToggle), 0);
    }

    #[tokio::test]
    async fn session_browser_builds_rich_entries_from_saved_sessions() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );
        let session = sample_browser_session(
            "sess-alpha-123456",
            "Debug auth flow",
            1_720_000_000.0,
            "openai/gpt-4o",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-alpha-123456",
            &edgecrab_types::Message::user("Review the OAuth callback regression."),
            1_720_000_010.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();

        assert!(app.session_browser.active);
        let entry = app
            .session_browser
            .items
            .iter()
            .find(|entry| entry.id == "sess-alpha-123456")
            .expect("session entry");
        assert_eq!(entry.display, "Debug auth flow");
        assert_eq!(entry.preview, "Review the OAuth callback regression.");
        assert!(entry.detail_view.contains("Model:     openai/gpt-4o"));
        assert!(entry.detail_view.contains("Actions: Enter inspect"));
    }

    #[tokio::test]
    async fn session_browser_query_surfaces_archived_message_content_hits() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-beta-654321",
            "Support triage",
            1_720_000_100.0,
            "anthropic/claude-opus-4.6",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-beta-654321",
            &edgecrab_types::Message::user("Need help tracing websocket reconnect jitter"),
            1_720_000_110.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();
        app.session_browser.query = "websocket jitter".into();
        app.refresh_session_browser();

        assert!(app.session_browser.active);
        assert!(app.session_browser.items.iter().any(|entry| {
            entry.id == "sess-beta-654321"
                && entry.matched_snippet.is_some()
                && entry
                    .matched_snippet
                    .as_deref()
                    .is_some_and(|snippet| snippet.contains("websocket"))
        }));
        assert_eq!(
            app.session_browser.current().map(|entry| entry.id.as_str()),
            Some("sess-beta-654321")
        );
    }

    #[tokio::test]
    async fn session_inspector_opens_and_escape_returns_to_browser() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-gamma-222222",
            "Tool chain debug",
            1_720_000_200.0,
            "openai/gpt-4.1",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-gamma-222222",
            &edgecrab_types::Message::user("Run cargo clippy on the CLI crate"),
            1_720_000_210.0,
        )
        .expect("save user");
        db.save_message(
            "sess-gamma-222222",
            &edgecrab_types::Message::assistant("I will inspect the warnings first."),
            1_720_000_220.0,
        )
        .expect("save assistant");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();

        let entry = app
            .session_browser
            .items
            .iter()
            .find(|entry| entry.id == "sess-gamma-222222")
            .cloned()
            .expect("session entry");
        app.open_session_inspector(entry);

        assert!(app.session_inspector.active());
        assert!(!app.session_browser.active);
        assert_eq!(app.session_inspector.selector.items.len(), 2);
        assert_eq!(
            app.session_inspector
                .session
                .as_ref()
                .map(|session| session.id.as_str()),
            Some("sess-gamma-222222")
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert!(!app.session_inspector.active());
        assert!(app.session_browser.active);
    }

    #[tokio::test]
    async fn session_inspector_prefers_matching_message_from_browser_search_hit() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-hit-777777",
            "Reconnect debugging",
            1_720_000_230.0,
            "anthropic/claude-opus-4.6",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-hit-777777",
            &edgecrab_types::Message::user("Initial notes about the outage"),
            1_720_000_231.0,
        )
        .expect("save user");
        db.save_message(
            "sess-hit-777777",
            &edgecrab_types::Message::assistant(
                "Tracing websocket reconnect jitter through the retry loop now.",
            ),
            1_720_000_232.0,
        )
        .expect("save assistant");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser_with_query(Some("websocket jitter"));

        let entry = app
            .session_browser
            .current()
            .cloned()
            .expect("search hit entry");
        app.open_session_inspector(entry);

        assert_eq!(
            app.session_inspector
                .selector
                .current()
                .map(|entry| (entry.index, entry.role_label.as_str())),
            Some((1, "assistant"))
        );

        let detail = plain_lines_text(&app.build_session_inspector_detail_lines());
        assert!(detail.contains("Opened from browser match: assistant ->"));
        assert!(detail.contains("Selected message\n#2   assistant"));
        assert!(detail.contains("Tracing websocket reconnect jitter through the retry loop now."));
    }

    #[tokio::test]
    async fn session_inspector_detail_tracks_selected_message() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-detail-888888",
            "Inspector sync",
            1_720_000_240.0,
            "openai/gpt-4.1",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-detail-888888",
            &edgecrab_types::Message::user("First unique message"),
            1_720_000_241.0,
        )
        .expect("save first");
        db.save_message(
            "sess-detail-888888",
            &edgecrab_types::Message::assistant("Second unique reply"),
            1_720_000_242.0,
        )
        .expect("save second");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();

        let entry = app
            .session_browser
            .items
            .iter()
            .find(|entry| entry.id == "sess-detail-888888")
            .cloned()
            .expect("session entry");
        app.open_session_inspector(entry);

        let initial_detail = plain_lines_text(&app.build_session_inspector_detail_lines());
        assert!(initial_detail.contains("Selected message\n#1   user"));
        assert!(initial_detail.contains("First unique message"));

        app.handle_key_event(event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));

        let moved_detail = plain_lines_text(&app.build_session_inspector_detail_lines());
        assert_eq!(
            app.session_inspector
                .selector
                .current()
                .map(|entry| entry.index),
            Some(1)
        );
        assert!(moved_detail.contains("Selected message\n#2   assistant"));
        assert!(moved_detail.contains("Second unique reply"));
    }

    #[tokio::test]
    async fn session_browser_lowercase_letters_stay_in_filter() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-delta-333333",
            "Resume rehearsal",
            1_720_000_300.0,
            "anthropic/claude-3-5-sonnet",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-delta-333333",
            &edgecrab_types::Message::user("Resume this later after lunch"),
            1_720_000_310.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));

        assert!(app.session_browser.active);
        assert_eq!(app.session_browser.query, "r");
        assert!(!app.session_inspector.active());
    }

    #[tokio::test]
    async fn session_slash_command_opens_live_current_session_inspector() {
        let agent = mock_agent();
        agent
            .inject_assistant_context("Live note for debugging")
            .await;

        let mut app = App::new();
        app.set_agent(agent);

        app.process_input("/session");

        assert!(app.session_inspector.active());
        assert!(!app.session_browser.active);
        assert_eq!(app.session_inspector.selector.items.len(), 1);
        assert_eq!(
            app.session_inspector
                .session
                .as_ref()
                .map(|session| session.title.as_str()),
            Some("Current session")
        );
        assert!(
            app.session_inspector
                .session
                .as_ref()
                .is_some_and(|session| session.is_live)
        );
        assert!(
            app.output.is_empty(),
            "rich inspector should replace shallow dump"
        );
    }

    #[tokio::test]
    async fn sessions_slash_command_opens_rich_browser_overlay() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-epsilon-444444",
            "Overlay test",
            1_720_000_400.0,
            "copilot/gpt-5-mini",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-epsilon-444444",
            &edgecrab_types::Message::user("Inspect via slash command"),
            1_720_000_410.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);

        app.process_input("/sessions");

        assert!(app.session_browser.active);
        assert!(
            app.output.is_empty(),
            "rich browser should replace shallow dump"
        );
    }

    #[tokio::test]
    async fn sessions_alias_search_seeds_browser_query() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-zeta-555555",
            "OAuth troubleshooting",
            1_720_000_500.0,
            "copilot/gpt-5-mini",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-zeta-555555",
            &edgecrab_types::Message::user("oauth callback mismatch debugging"),
            1_720_000_510.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);

        app.process_input("/sessions search oauth mismatch");

        assert!(app.session_browser.active);
        assert_eq!(app.session_browser.query, "oauth mismatch");
        assert_eq!(
            app.session_browser.current().map(|entry| entry.id.as_str()),
            Some("sess-zeta-555555")
        );
    }

    #[tokio::test]
    async fn session_browser_tab_and_page_down_drive_detail_pane() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-pane-666666",
            "Pane focus",
            1_720_000_600.0,
            "copilot/gpt-5-mini",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-pane-666666",
            &edgecrab_types::Message::user("One\nTwo\nThree\nFour\nFive\nSix\nSeven\nEight\nNine"),
            1_720_000_610.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();

        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert_eq!(app.session_browser_pane.focus, SplitPaneFocus::Detail);
        assert_eq!(app.session_browser_pane.scroll, 8);
        assert_eq!(app.session_browser.query, "");
    }

    #[tokio::test]
    async fn session_browser_fullscreen_esc_returns_to_split_view() {
        let dir = tempfile::tempdir().expect("tempdir");
        let db = Arc::new(
            edgecrab_state::SessionDb::open(&dir.path().join("sessions.db")).expect("session db"),
        );

        let session = sample_browser_session(
            "sess-full-777777",
            "Fullscreen detail",
            1_720_000_700.0,
            "copilot/gpt-5-mini",
        );
        db.save_session(&session).expect("save session");
        db.save_message(
            "sess-full-777777",
            &edgecrab_types::Message::user(
                "alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\niota",
            ),
            1_720_000_710.0,
        )
        .expect("save message");

        let agent = mock_agent_with_state_db(db);
        let mut app = App::new();
        app.set_agent(agent);
        app.open_session_browser();
        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(app.session_browser_pane.scroll, 8);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert!(app.session_browser.active);
        assert!(app.detail_fullscreen_active(DetailSurface::SessionBrowser));
        assert_eq!(
            app.detail_fullscreen_scroll(DetailSurface::SessionBrowser),
            16
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert!(app.session_browser.active);
        assert!(!app.detail_fullscreen_active(DetailSurface::SessionBrowser));
        assert_eq!(app.session_browser_pane.scroll, 16);
    }

    #[tokio::test]
    async fn current_session_inspector_tab_and_page_down_scroll_detail() {
        let agent = mock_agent();
        agent
            .inject_assistant_context("alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\niota")
            .await;

        let mut app = App::new();
        app.set_agent(agent);
        app.process_input("/session");

        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert_eq!(app.session_inspector.pane.focus, SplitPaneFocus::Detail);
        assert_eq!(app.session_inspector.pane.scroll, 8);
    }

    #[test]
    fn mcp_selector_builder_merges_configured_and_official_entries() {
        let configured = vec![edgecrab_tools::tools::mcp_client::ConfiguredMcpServer {
            name: "local-git".into(),
            enabled: true,
            url: None,
            bearer_token: None,
            oauth: None,
            command: "uvx".into(),
            args: vec![
                "mcp-server-git".into(),
                "--repository".into(),
                "/tmp/repo".into(),
            ],
            cwd: Some(std::path::PathBuf::from("/tmp/repo")),
            env: std::collections::HashMap::new(),
            headers: std::collections::HashMap::new(),
            timeout: None,
            connect_timeout: None,
            include: Vec::new(),
            exclude: Vec::new(),
            token_from_config: false,
            token_from_store: false,
        }];

        let entries = build_mcp_selector_entries_from(
            &configured,
            &[crate::mcp_catalog::OfficialCatalogEntry {
                id: "git".into(),
                display_name: "Git".into(),
                description: "Official git server.".into(),
                source_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/git"
                    .into(),
                homepage: "https://github.com/modelcontextprotocol/servers/tree/main/src/git"
                    .into(),
                tags: vec!["official".into(), "reference".into()],
                installable_preset_id: Some("git".into()),
            }],
        );

        assert!(entries.iter().any(|entry| {
            entry.kind == McpEntryKind::ConfiguredServer && entry.id == "local-git"
        }));
        assert!(
            entries
                .iter()
                .any(|entry| entry.kind == McpEntryKind::CatalogEntry && entry.id == "git")
        );
    }

    #[test]
    fn mcp_selector_search_matches_preset_package_and_env_metadata() {
        let entries = build_mcp_selector_entries_from(
            &[],
            &[crate::mcp_catalog::OfficialCatalogEntry {
                id: "github".into(),
                display_name: "GitHub".into(),
                description: "Official GitHub server.".into(),
                source_url: "https://github.com/github/github-mcp-server".into(),
                homepage: "https://modelcontextprotocol.io".into(),
                tags: vec!["official".into(), "integration".into(), "github".into()],
                installable_preset_id: Some("github".into()),
            }],
        );
        let mut selector = FuzzySelector::new();
        selector.set_items(entries);
        selector.query = "GITHUB_PERSONAL_ACCESS_TOKEN".into();
        selector.update_filter();

        assert_eq!(selector.filtered.len(), 1);
        assert_eq!(
            selector.current().map(|entry| entry.id.as_str()),
            Some("github")
        );
    }

    #[test]
    fn mcp_selector_builds_rich_detail_view_for_catalog_entries() {
        let entries = build_mcp_selector_entries_from(
            &[],
            &[crate::mcp_catalog::OfficialCatalogEntry {
                id: "github".into(),
                display_name: "GitHub".into(),
                description: "Official GitHub server.".into(),
                source_url: "https://github.com/github/github-mcp-server".into(),
                homepage: "https://modelcontextprotocol.io".into(),
                tags: vec!["official".into(), "integration".into()],
                installable_preset_id: Some("github".into()),
            }],
        );

        let entry = entries
            .into_iter()
            .find(|entry| entry.id == "github")
            .expect("github entry");

        assert!(entry.detail_view.contains("Catalog: github"));
        assert!(
            entry
                .detail_view
                .contains("Pkg:     @modelcontextprotocol/server-github")
        );
        assert!(entry.detail_view.contains("Status:  ready to install"));
    }

    #[test]
    fn mcp_selector_builds_rich_detail_view_for_configured_servers() {
        let entries = build_mcp_selector_entries_from(
            &[edgecrab_tools::tools::mcp_client::ConfiguredMcpServer {
                name: "remote-http".into(),
                enabled: true,
                url: Some("https://example.com/mcp".into()),
                bearer_token: None,
                oauth: None,
                command: "ignored".into(),
                args: vec![],
                cwd: Some(std::path::PathBuf::from("/tmp/workspace")),
                env: std::collections::HashMap::from([
                    ("EDGE_API_KEY".into(), "value".into()),
                    ("EDGE_PROJECT".into(), "demo".into()),
                ]),
                headers: std::collections::HashMap::from([
                    ("X-Edge".into(), "1".into()),
                    ("Authorization".into(), "Bearer token".into()),
                ]),
                timeout: None,
                connect_timeout: None,
                include: vec!["repo/*".into()],
                exclude: vec!["admin/*".into()],
                token_from_config: false,
                token_from_store: true,
            }],
            &[],
        );

        let entry = entries
            .into_iter()
            .find(|entry| entry.id == "remote-http")
            .expect("remote-http entry");

        assert!(entry.detail_view.contains("Server: remote-http"));
        assert!(
            entry
                .detail_view
                .contains("Transport: http https://example.com/mcp")
        );
        assert!(
            entry
                .detail_view
                .contains("Env keys: EDGE_API_KEY, EDGE_PROJECT")
        );
        assert!(
            entry
                .detail_view
                .contains("Header keys: Authorization, X-Edge")
        );
    }

    #[test]
    fn mcp_selector_uses_enable_as_default_action_for_disabled_servers() {
        let entries = build_mcp_selector_entries_from(
            &[edgecrab_tools::tools::mcp_client::ConfiguredMcpServer {
                name: "disabled-http".into(),
                enabled: false,
                url: Some("https://example.com/mcp".into()),
                bearer_token: None,
                oauth: None,
                command: "ignored".into(),
                args: vec![],
                cwd: None,
                env: std::collections::HashMap::new(),
                headers: std::collections::HashMap::new(),
                timeout: None,
                connect_timeout: None,
                include: Vec::new(),
                exclude: Vec::new(),
                token_from_config: false,
                token_from_store: false,
            }],
            &[],
        );

        let entry = entries
            .into_iter()
            .find(|entry| entry.id == "disabled-http")
            .expect("disabled entry");

        assert_eq!(entry.action_label, "enable");
        assert_eq!(entry.default_command(), "enable disabled-http");
        assert!(entry.detail.contains("disabled"));
        assert!(entry.detail_view.contains("State: disabled"));
    }

    #[tokio::test]
    async fn mcp_command_without_args_opens_selector_overlay() {
        let mut app = App::new();

        app.handle_mcp_command(String::new());

        assert!(app.mcp_selector.active);
        assert!(!app.mcp_selector.filtered.is_empty());
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn mcp_selector_shows_disabled_servers_from_config() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        std::fs::write(
            dir.path().join("config.yaml"),
            "mcp_servers:\n  disabled-http:\n    url: https://example.com/mcp\n    enabled: false\n",
        )
        .expect("config");

        let mut app = App::new();
        app.handle_mcp_command(String::new());

        assert!(app.mcp_selector.active);
        assert!(
            app.mcp_selector
                .items
                .iter()
                .any(|entry| entry.id == "disabled-http" && !entry.enabled)
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn mcp_enable_and_disable_commands_persist_server_state() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        std::fs::write(
            dir.path().join("config.yaml"),
            "mcp_servers:\n  github:\n    url: https://example.com/mcp\n    enabled: false\n",
        )
        .expect("config");

        let mut app = App::new();
        app.handle_mcp_command("enable github".into());

        let cfg = edgecrab_core::AppConfig::load_from(&dir.path().join("config.yaml"))
            .expect("config after enable");
        assert!(
            cfg.mcp_servers
                .get("github")
                .is_some_and(|server| server.enabled)
        );

        app.handle_mcp_command("disable github".into());

        let cfg = edgecrab_core::AppConfig::load_from(&dir.path().join("config.yaml"))
            .expect("config after disable");
        assert!(
            cfg.mcp_servers
                .get("github")
                .is_some_and(|server| !server.enabled)
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn mcp_search_filters_official_snapshot_entries() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        let cache_dir = dir.path().join("cache");
        std::fs::create_dir_all(&cache_dir).expect("cache dir");
        std::fs::write(
            cache_dir.join("mcp_official_catalog.json"),
            serde_json::to_vec(&serde_json::json!({
                "fetched_at_epoch_secs": 1,
                "entries": [{
                    "id": "time",
                    "display_name": "Time",
                    "description": "Timezone conversion capabilities.",
                    "source_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
                    "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
                    "tags": ["official", "reference"],
                    "installable_preset_id": "time"
                }]
            }))
            .expect("json"),
        )
        .expect("write cache");

        let mut app = App::new();

        app.open_mcp_selector(Some("timezone"), false);

        assert!(app.mcp_selector.active);
        let visible: Vec<&str> = app
            .mcp_selector
            .filtered
            .iter()
            .map(|idx| app.mcp_selector.items[*idx].title.as_str())
            .collect();
        assert!(
            visible.iter().any(|title| title.contains("Time")),
            "expected the official time preset in filtered entries, got: {visible:?}"
        );
        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn mcp_search_command_opens_remote_browser_with_query() {
        let mut app = App::new();

        app.handle_mcp_command("search github".into());

        assert!(app.remote_mcp_browser.selector.active);
        assert_eq!(app.remote_mcp_browser.selector.query, "github");
    }

    #[test]
    fn build_remote_mcp_entries_preserves_source_notices_and_actions() {
        let report = crate::mcp_catalog::McpSearchReport {
            groups: vec![
                crate::mcp_catalog::McpSearchGroup {
                    source: crate::mcp_catalog::McpSearchSourceInfo {
                        id: "mcp-reference".into(),
                        label: "MCP Reference".into(),
                        origin: "https://github.com/modelcontextprotocol/servers".into(),
                        trust_level: "official".into(),
                    },
                    results: vec![crate::mcp_catalog::McpSearchEntry {
                        id: "time".into(),
                        name: "Time".into(),
                        description: "Timezone server".into(),
                        source: "official-catalog".into(),
                        origin:
                            "https://github.com/modelcontextprotocol/servers/tree/main/src/time"
                                .into(),
                        homepage: None,
                        tags: vec!["official".into(), "reference".into()],
                        transport: None,
                        install: Some(crate::mcp_catalog::McpInstallPlan::Preset {
                            preset_id: "time".into(),
                        }),
                    }],
                    notice: None,
                },
                crate::mcp_catalog::McpSearchGroup {
                    source: crate::mcp_catalog::McpSearchSourceInfo {
                        id: "mcp-registry".into(),
                        label: "MCP Registry".into(),
                        origin: "https://registry.modelcontextprotocol.io".into(),
                        trust_level: "official".into(),
                    },
                    results: vec![crate::mcp_catalog::McpSearchEntry {
                        id: "vendor/view-only".into(),
                        name: "View Only".into(),
                        description: "Needs unsupported transport".into(),
                        source: "mcp-registry".into(),
                        origin: "https://registry.modelcontextprotocol.io".into(),
                        homepage: None,
                        tags: vec!["official".into(), "registry".into(), "view-only".into()],
                        transport: Some("sse".into()),
                        install: None,
                    }],
                    notice: Some("Registry search failed over to cached results".into()),
                },
            ],
        };

        let (entries, notices) = App::build_remote_mcp_entries(&report);

        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].action(), RemoteMcpAction::Install);
        assert_eq!(entries[1].action(), RemoteMcpAction::View);
        assert!(notices.iter().any(|notice| notice.contains("MCP Registry")));
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn build_remote_skill_entries_marks_update_replace_and_install() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let skills_dir = dir.path().join("skills");
        std::fs::create_dir_all(skills_dir.join(".hub")).expect("hub dir");
        std::fs::create_dir_all(skills_dir.join("local-only")).expect("local-only");
        std::fs::write(skills_dir.join("local-only").join("SKILL.md"), "# local").expect("skill");
        std::fs::write(
            skills_dir.join(".hub").join("lock.json"),
            serde_json::to_vec(&serde_json::json!({
                "native-mcp": {
                    "source": "official",
                    "identifier": "edgecrab:mcp/native-mcp",
                    "installed_at": "2026-04-07T00:00:00Z",
                    "content_hash": "sha256:test"
                }
            }))
            .expect("json"),
        )
        .expect("write lock");

        let report = edgecrab_tools::tools::skills_hub::SearchReport {
            groups: vec![edgecrab_tools::tools::skills_hub::SearchGroup {
                source: edgecrab_tools::tools::skills_hub::HubSourceInfo {
                    id: "edgecrab".into(),
                    label: "EdgeCrab".into(),
                    origin: "https://github.com/raphaelmansuy/edgecrab".into(),
                    trust_level: "trusted".into(),
                },
                results: vec![
                    edgecrab_tools::tools::skills_hub::SkillMeta {
                        name: "native-mcp".into(),
                        description: "Native MCP skill".into(),
                        source: "edgecrab".into(),
                        origin: "https://github.com/raphaelmansuy/edgecrab".into(),
                        identifier: "edgecrab:mcp/native-mcp".into(),
                        trust_level: "trusted".into(),
                        repo: None,
                        path: None,
                        url: None,
                        tags: vec!["mcp".into()],
                    },
                    edgecrab_tools::tools::skills_hub::SkillMeta {
                        name: "local-only".into(),
                        description: "Would replace a local skill".into(),
                        source: "edgecrab".into(),
                        origin: "https://github.com/raphaelmansuy/edgecrab".into(),
                        identifier: "edgecrab:tools/local-only".into(),
                        trust_level: "trusted".into(),
                        repo: None,
                        path: None,
                        url: None,
                        tags: vec!["tools".into()],
                    },
                    edgecrab_tools::tools::skills_hub::SkillMeta {
                        name: "fresh-remote".into(),
                        description: "Brand new remote skill".into(),
                        source: "edgecrab".into(),
                        origin: "https://github.com/raphaelmansuy/edgecrab".into(),
                        identifier: "edgecrab:tools/fresh-remote".into(),
                        trust_level: "trusted".into(),
                        repo: None,
                        path: None,
                        url: None,
                        tags: vec!["tools".into()],
                    },
                ],
                notice: Some("using cached index after source timeout".into()),
            }],
        };

        let (entries, notices) = App::build_remote_skill_entries(&report);
        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].action, RemoteSkillAction::Update);
        assert_eq!(entries[1].action, RemoteSkillAction::Replace);
        assert_eq!(entries[2].action, RemoteSkillAction::Install);
        assert_eq!(notices.len(), 1);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn skills_search_opens_remote_browser_with_query() {
        let mut app = App::new();

        app.handle_show_skills("search native mcp".into());

        assert!(app.remote_skill_browser.selector.active);
        assert_eq!(app.remote_skill_browser.selector.query, "native mcp");
        assert!(!app.skill_selector.active);
    }

    #[tokio::test]
    async fn skills_hub_without_query_opens_remote_browser() {
        let mut app = App::new();

        app.handle_show_skills("hub".into());

        assert!(app.remote_skill_browser.selector.active);
        assert!(app.remote_skill_browser.selector.query.is_empty());
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn build_remote_plugin_entries_marks_update_replace_and_install() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let plugins_dir = dir.path().join("plugins");
        std::fs::create_dir_all(plugins_dir.join("native-plugin")).expect("native plugin dir");
        std::fs::create_dir_all(plugins_dir.join("local-only")).expect("local plugin dir");
        std::fs::write(
            plugins_dir.join("native-plugin").join("plugin.toml"),
            r#"
[plugin]
name = "native-plugin"
version = "1.0.0"
description = "Native plugin"
kind = "skill"

[trust]
level = "community"
source = "hub:edgecrab-official/native-plugin"
"#,
        )
        .expect("write plugin");
        std::fs::write(
            plugins_dir.join("local-only").join("plugin.toml"),
            r#"
[plugin]
name = "local-only"
version = "1.0.0"
description = "Local plugin"
kind = "skill"
"#,
        )
        .expect("write local plugin");

        let report = edgecrab_plugins::PluginSearchReport {
            groups: vec![edgecrab_plugins::PluginSearchGroup {
                source: edgecrab_plugins::PluginHubSourceInfo {
                    name: "edgecrab-official".into(),
                    label: "edgecrab-official".into(),
                    trust_level: edgecrab_plugins::TrustLevel::Official,
                    description: "Official EdgeCrab plugin registry".into(),
                    url: "https://plugins.edgecrab.sh/index.json".into(),
                },
                results: vec![
                    edgecrab_plugins::PluginMeta {
                        name: "native-plugin".into(),
                        identifier: "hub:edgecrab-official/native-plugin".into(),
                        description: "Hub-managed plugin".into(),
                        version: "1.1.0".into(),
                        kind: edgecrab_plugins::PluginKind::Skill,
                        origin: "https://plugins.edgecrab.sh/native-plugin".into(),
                        trust_level: "official".into(),
                        tags: vec!["official".into()],
                        install_url: "https://plugins.edgecrab.sh/native-plugin.zip".into(),
                        requires_env: vec![],
                    },
                    edgecrab_plugins::PluginMeta {
                        name: "local-only".into(),
                        identifier: "hub:edgecrab-official/local-only".into(),
                        description: "Would replace local plugin".into(),
                        version: "1.1.0".into(),
                        kind: edgecrab_plugins::PluginKind::Skill,
                        origin: "https://plugins.edgecrab.sh/local-only".into(),
                        trust_level: "official".into(),
                        tags: vec!["official".into()],
                        install_url: "https://plugins.edgecrab.sh/local-only.zip".into(),
                        requires_env: vec!["PLUGIN_TOKEN".into()],
                    },
                    edgecrab_plugins::PluginMeta {
                        name: "fresh-remote".into(),
                        identifier: "hub:edgecrab-official/fresh-remote".into(),
                        description: "Brand new plugin".into(),
                        version: "0.1.0".into(),
                        kind: edgecrab_plugins::PluginKind::ToolServer,
                        origin: "https://plugins.edgecrab.sh/fresh-remote".into(),
                        trust_level: "official".into(),
                        tags: vec!["tool-server".into()],
                        install_url: "https://plugins.edgecrab.sh/fresh-remote.zip".into(),
                        requires_env: vec![],
                    },
                ],
                notice: Some("using cached index after source timeout".into()),
            }],
        };

        let (entries, notices) = App::build_remote_plugin_entries(&report);
        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].action, RemotePluginAction::Update);
        assert_eq!(entries[1].action, RemotePluginAction::Replace);
        assert_eq!(entries[2].action, RemotePluginAction::Install);
        assert_eq!(entries[1].requires_env, vec!["PLUGIN_TOKEN"]);
        assert_eq!(notices.len(), 1);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn plugins_search_opens_remote_browser_with_query() {
        let mut app = App::new();

        app.handle_show_plugins("search native plugin".into());

        assert!(app.remote_plugin_browser.selector.active);
        assert_eq!(app.remote_plugin_browser.selector.query, "native plugin");
        assert!(!app.plugin_toggle.active);
    }

    #[tokio::test]
    async fn plugins_search_with_source_filter_opens_remote_browser() {
        let mut app = App::new();

        app.handle_show_plugins("search --source hermes weather".into());

        assert!(app.remote_plugin_browser.selector.active);
        assert_eq!(app.remote_plugin_browser.selector.query, "weather");
        assert_eq!(
            app.remote_plugin_browser.source_filter.as_deref(),
            Some("hermes")
        );
    }

    #[tokio::test]
    async fn plugins_browse_without_query_opens_remote_browser() {
        let mut app = App::new();

        app.handle_show_plugins("browse".into());

        assert!(app.remote_plugin_browser.selector.active);
        assert!(app.remote_plugin_browser.selector.query.is_empty());
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn plugins_without_args_open_local_overlay() {
        let mut app = App::new();

        app.handle_show_plugins(String::new());

        assert!(app.plugin_toggle.active);
        assert!(!app.remote_plugin_browser.selector.active);
    }

    #[tokio::test]
    async fn plugin_selector_lowercase_letters_stay_in_filter() {
        let mut app = App::new();
        app.plugin_toggle.set_items(vec![PluginToggleEntry {
            name: "json-toolbox".into(),
            display_name: "json-toolbox".into(),
            description: "JSON validation tools".into(),
            version: "1.0.0".into(),
            source: "user".into(),
            kind: "hermes".into(),
            tool_count: 2,
            estimated_tokens: 0,
            check_state: PluginCheckState::On,
            original_check_state: PluginCheckState::On,
            runtime_status: "running".into(),
            trust_level: "official".into(),
            tools: vec!["json_validate".into(), "json_pointer_get".into()],
            cli_commands: vec!["json-toolbox".into()],
            missing_env: Vec::new(),
            related_skills: vec!["api-debugging".into()],
            compatibility: Some("Requires json-toolbox plugin".into()),
            install_source: Some("./plugins/developer/json-toolbox".into()),
            credentials_satisfied: true,
            search_text: "json-toolbox hermes running official json_validate json_pointer_get"
                .into(),
        }]);
        app.plugin_toggle.activate();

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));

        assert!(app.plugin_toggle.active);
        assert_eq!(app.plugin_toggle.query, "j");
        assert!(!app.remote_plugin_browser.selector.active);
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn refresh_skills_list_builds_rich_entries_for_bundle_and_flat_skills() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let skills_dir = dir.path().join("skills");
        std::fs::create_dir_all(skills_dir.join("automation").join("deploy")).expect("skill dir");
        std::fs::create_dir_all(
            skills_dir
                .join("automation")
                .join("deploy")
                .join("references"),
        )
        .expect("references");
        std::fs::write(
            skills_dir
                .join("automation")
                .join("deploy")
                .join("SKILL.md"),
            "---\nname: deploy-pro\n\
             description: Deploy services safely\n\
             ---\n\
             # Deploy\n\
             Preview line one\n\
             Preview line two\n",
        )
        .expect("write bundle skill");
        std::fs::write(
            skills_dir
                .join("automation")
                .join("deploy")
                .join("references")
                .join("runbook.md"),
            "# Runbook",
        )
        .expect("write support file");
        std::fs::write(
            skills_dir.join("quickstart.md"),
            "---\ndescription: Quick command snippets\n---\n# Quickstart\nUse this for shell snippets.\n",
        )
        .expect("write flat skill");

        let mut app = App::new();
        app.active_skills.push("deploy".into());
        app.refresh_skills_list();

        let deploy = app
            .skill_selector
            .items
            .iter()
            .find(|entry| entry.name == "deploy")
            .expect("deploy skill");
        assert!(deploy.active);
        assert_eq!(deploy.category, "automation");
        assert!(deploy.display_title().contains("deploy-pro"));
        assert!(deploy.detail_view.contains("Supporting files: 1"));
        assert!(deploy.detail_view.contains("Preview:"));

        let quick = app
            .skill_selector
            .items
            .iter()
            .find(|entry| entry.name == "quickstart")
            .expect("flat skill");
        assert!(!quick.is_dir);
        assert!(quick.detail_view.contains("single markdown skill"));
        assert!(quick.detail_view.contains("Path: quickstart.md"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn activate_skill_supports_flat_markdown_files() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let skills_dir = dir.path().join("skills");
        std::fs::create_dir_all(&skills_dir).expect("skills dir");
        std::fs::write(
            skills_dir.join("quickstart.md"),
            "---\ndescription: Quick command snippets\n---\n# Quickstart\nUse this for shell snippets.\n",
        )
        .expect("write flat skill");

        let mut app = App::new();
        app.activate_skill("quickstart");

        assert!(app.active_skills.iter().any(|entry| entry == "quickstart"));
        assert!(
            app.output
                .last()
                .expect("activation output")
                .text
                .contains("activated")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn activate_skill_updates_agent_preloaded_skills() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let skills_dir = dir.path().join("skills");
        std::fs::create_dir_all(&skills_dir).expect("skills dir");
        std::fs::write(
            skills_dir.join("quickstart.md"),
            "---\ndescription: Quick command snippets\n---\n# Quickstart\nUse this for shell snippets.\n",
        )
        .expect("write flat skill");

        let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
        let (mut app, agent) = rt.block_on(async { (App::new(), mock_agent()) });
        app.set_agent(agent.clone());
        app.activate_skill("quickstart");

        assert_eq!(
            app.rt_handle.block_on(agent.preloaded_skills()),
            vec!["quickstart"]
        );

        app.activate_skill("quickstart");
        assert!(app.rt_handle.block_on(agent.preloaded_skills()).is_empty());

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    fn selector_search_and_action_keys_are_separated() {
        let lower = event::KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
        let upper = event::KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT);

        assert_eq!(selector_search_char(&lower), Some('i'));
        assert_eq!(selector_search_char(&upper), None);
        assert!(!selector_action_key(&lower, 'i'));
        assert!(selector_action_key(&upper, 'i'));
    }

    #[tokio::test]
    async fn mcp_selector_lowercase_letters_stay_in_filter() {
        let mut app = App::new();
        app.handle_mcp_command(String::new());
        assert!(app.mcp_selector.active);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));

        assert!(app.mcp_selector.active);
        assert_eq!(app.mcp_selector.query, "i");
    }

    #[tokio::test]
    async fn skill_selector_lowercase_letters_stay_in_filter() {
        let mut app = App::new();
        app.skill_selector.set_items(vec![SkillEntry {
            name: "rust".into(),
            display_name: "rust".into(),
            is_dir: true,
            active: false,
            category: "coding".into(),
            relative_path: "coding/rust".into(),
            desc: "Rust helper".into(),
            detail: "coding | ready".into(),
            detail_view: "State: available".into(),
            search_text: "rust coding helper".into(),
        }]);
        app.skill_selector.activate();

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));

        assert!(app.skill_selector.active);
        assert_eq!(app.skill_selector.query, "r");
        assert!(!app.remote_skill_browser.selector.active);
    }

    #[test]
    fn select_audio_player_prefers_first_available_candidate() {
        let player = select_audio_player_with(|program| matches!(program, "mpv" | "ffplay"));
        assert_eq!(player.map(|player| player.program), Some("ffplay"));
    }

    #[test]
    fn select_audio_recorder_prefers_linux_native_recorders_before_ffmpeg() {
        let recorder =
            select_audio_recorder_with(|program| matches!(program, "ffmpeg" | "arecord"));
        let expected = if cfg!(target_os = "macos") {
            Some(AudioRecorderBackend::FfmpegAvFoundation)
        } else if cfg!(target_os = "windows") {
            Some(AudioRecorderBackend::FfmpegDShow)
        } else {
            Some(AudioRecorderBackend::Arecord)
        };
        assert_eq!(recorder, expected);
    }

    #[test]
    fn recorder_support_messages_match_backend_capabilities() {
        assert!(recorder_supports_auto_silence_stop(
            AudioRecorderBackend::FfmpegAvFoundation
        ));
        assert!(recorder_supports_auto_silence_stop(
            AudioRecorderBackend::Rec
        ));
        assert!(!recorder_supports_auto_silence_stop(
            AudioRecorderBackend::FfmpegDShow
        ));
        assert!(
            recorder_auto_silence_support_message(AudioRecorderBackend::FfmpegDShow)
                .contains("Windows")
        );
    }

    #[test]
    fn ffmpeg_silence_parser_detects_post_speech_stop_conditions() {
        assert_eq!(
            parse_ffmpeg_silence_start("[silencedetect] silence_start: 0.000"),
            Some(0.0)
        );
        assert!(line_mentions_ffmpeg_silence_end(
            "[silencedetect] silence_end: 1.23 | silence_duration: 0.45"
        ));
        assert!(!should_stop_ffmpeg_on_silence(
            "[silencedetect] silence_start: 0.000",
            false,
            0.35
        ));
        assert!(should_stop_ffmpeg_on_silence(
            "[silencedetect] silence_start: 1.420",
            false,
            0.35
        ));
        assert!(should_stop_ffmpeg_on_silence(
            "[silencedetect] silence_start: 0.100",
            true,
            0.35
        ));
    }

    #[test]
    fn voice_hallucination_filter_is_conservative() {
        assert_eq!(
            normalize_voice_transcript("  thanks   for   watching  "),
            "thanks for watching"
        );
        assert!(is_probable_voice_hallucination("thanks for watching", 0.9));
        assert!(!is_probable_voice_hallucination(
            "thanks for watching the logs and open src/main.rs",
            0.9
        ));
        assert!(!is_probable_voice_hallucination("thank you", 2.5));
    }

    #[test]
    fn waiting_first_token_status_surfaces_the_right_message() {
        let theme = Theme::default();
        let early = format_waiting_first_token_status(&theme, 0, 0, 0, 2);
        let long = format_waiting_first_token_status(&theme, 0, 0, 0, 12);
        assert!(early.contains("first token"));
        assert!(long.contains("waiting for first token"));
        assert!(long.contains("^C=stop"));
    }

    #[test]
    fn voice_presence_badges_cover_recording_and_playback_modes() {
        let recording = format_voice_presence_badge(
            VoicePresenceState::Recording {
                elapsed_secs: 5,
                continuous: true,
            },
            2,
        );
        let speaking = format_voice_presence_badge(VoicePresenceState::Speaking, 2);
        let listening = format_voice_presence_badge(VoicePresenceState::Listening, 2);
        assert!(recording.contains("TALK"));
        assert!(recording.contains("5s"));
        assert!(speaking.contains("SPEAK"));
        assert!(listening.contains("LISTEN"));
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn persist_voice_preferences_round_trip() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        persist_voice_enabled_to_config(true).expect("persist enabled");
        persist_voice_preferences_to_config(Some(true), Some(false), Some(Some("Mic 1")))
            .expect("persist voice prefs");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.voice.enabled);
        assert!(cfg.voice.continuous);
        assert!(!cfg.voice.hallucination_filter);
        assert_eq!(cfg.voice.input_device.as_deref(), Some("Mic 1"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn persist_display_preferences_round_trip_includes_tool_progress_and_status_bar() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        persist_display_preferences(
            Some(true),
            Some(false),
            Some(ToolProgressMode::Verbose),
            Some(false),
        )
        .expect("persist display prefs");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.display.show_reasoning);
        assert!(!cfg.display.streaming);
        assert!(!cfg.model.streaming);
        assert_eq!(cfg.display.tool_progress, ToolProgressMode::Verbose);
        assert!(!cfg.display.show_status_bar);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn persist_smart_routing_round_trip() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        persist_smart_routing_to_config(&edgecrab_core::config::SmartRoutingYaml {
            enabled: true,
            cheap_model: "copilot/gpt-4.1-mini".into(),
            cheap_base_url: None,
            cheap_api_key_env: None,
        })
        .expect("persist smart routing");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.model.smart_routing.enabled);
        assert_eq!(cfg.model.smart_routing.cheap_model, "copilot/gpt-4.1-mini");

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn persist_moa_config_round_trip() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        persist_moa_config_to_config(&edgecrab_core::config::MoaConfig {
            enabled: false,
            aggregator_model: "anthropic/claude-opus-4.6".into(),
            reference_models: vec!["anthropic/claude-opus-4.6".into(), "openai/gpt-4.1".into()],
        })
        .expect("persist moa");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(!cfg.moa.enabled);
        assert_eq!(cfg.moa.aggregator_model, "anthropic/claude-opus-4.6");
        assert_eq!(cfg.moa.reference_models.len(), 2);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn approve_command_resolves_pending_overlay() {
        let mut app = App::new();
        let (tx, rx) = tokio::sync::oneshot::channel();
        app.approval_pending_tx = Some(tx);
        app.display_state = DisplayState::WaitingForApproval {
            command: "rm".into(),
            full_command: "rm -rf /tmp/demo".into(),
            selected: 0,
            show_full: false,
            scroll_offset: 0,
        };

        app.handle_approval_choice_command(edgecrab_core::ApprovalChoice::Session);

        assert_eq!(
            rx.await.expect("approval choice"),
            edgecrab_core::ApprovalChoice::Session
        );
        assert!(matches!(
            app.display_state,
            DisplayState::AwaitingFirstToken { .. }
        ));
        assert_eq!(app.session_approvals.len(), 1);
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn sethome_updates_single_enabled_platform_without_explicit_platform_arg() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let mut cfg = edgecrab_core::AppConfig::default();
        cfg.gateway.telegram.enabled = true;
        cfg.gateway.enable_platform("telegram");
        cfg.save().expect("save config");

        let mut app = App::new();
        app.handle_set_home_channel("123456".into());

        let reloaded = edgecrab_core::AppConfig::load().expect("load config");
        assert_eq!(
            reloaded.gateway.telegram.home_channel.as_deref(),
            Some("123456")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn config_command_opens_overlay() {
        let mut app = App::new();
        app.handle_config_command(String::new());
        assert!(app.config_selector.active);
        assert!(!app.config_selector.filtered.is_empty());
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn worktree_command_opens_document_overlay() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let mut app = App::new();
        app.handle_worktree_command(String::new());

        let overlay = app.document_overlay.as_ref().expect("document overlay");
        assert_eq!(overlay.title, "Git Worktree Mode");
        assert!(overlay.body.contains("Git worktree status:"));
        assert!(overlay.body.contains("Launch default:"));
        assert!(overlay.body.contains("/worktree on"));

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn log_command_opens_rich_browser_overlay() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        std::fs::create_dir_all(dir.path().join("logs")).expect("create logs");
        std::fs::write(dir.path().join("logs").join("agent.log"), "INFO hello log")
            .expect("write log");

        let mut app = App::new();
        app.handle_log_command(String::new());

        assert!(app.log_browser.active);
        assert_eq!(app.log_browser.items.len(), 1);
        assert_eq!(app.log_browser.items[0].name, "agent.log");

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn log_level_command_persists_config_default() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let mut app = App::new();
        app.handle_log_command("level debug".into());

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert_eq!(cfg.logging.level, "debug");

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn log_follow_refreshes_browser_when_new_file_arrives() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        std::fs::create_dir_all(dir.path().join("logs")).expect("create logs");
        std::fs::write(dir.path().join("logs").join("agent.log"), "INFO first")
            .expect("write first log");

        let mut app = App::new();
        app.handle_log_command(String::new());
        assert_eq!(app.log_browser.items.len(), 1);

        std::fs::write(dir.path().join("logs").join("gateway.log"), "WARN second")
            .expect("write second log");
        app.log_follow.last_refresh = Instant::now() - LOG_FOLLOW_REFRESH_INTERVAL;
        app.refresh_log_follow_if_due_at(Instant::now());

        assert_eq!(app.log_browser.items.len(), 2);
        assert!(
            app.log_browser_status_note
                .as_deref()
                .is_some_and(|note| note.contains("follow on"))
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn log_follow_refreshes_inspector_tail_with_new_entries() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        std::fs::create_dir_all(dir.path().join("logs")).expect("create logs");
        let path = dir.path().join("logs").join("agent.log");
        std::fs::write(&path, "2026-04-12 INFO first line\n").expect("write initial log");

        let mut app = App::new();
        app.handle_log_command(String::new());
        let entry = app
            .log_browser
            .current()
            .cloned()
            .expect("log browser entry");
        app.open_log_inspector(entry);
        assert_eq!(app.log_inspector.selector.filtered.len(), 1);

        std::fs::write(
            &path,
            "2026-04-12 INFO first line\n2026-04-12 ERROR second line\n",
        )
        .expect("append second line");
        app.log_follow.last_refresh = Instant::now() - LOG_FOLLOW_REFRESH_INTERVAL;
        app.refresh_log_follow_if_due_at(Instant::now());

        assert_eq!(app.log_inspector.selector.filtered.len(), 2);
        let current = app
            .log_inspector
            .selector
            .current()
            .expect("selected log entry");
        assert!(current.detail.contains("second line"));

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE));
        assert!(!app.log_follow.enabled);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn persist_worktree_preference_round_trip() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        persist_worktree_enabled_to_config(true).expect("persist worktree");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.worktree);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn config_selector_detail_focus_pages_and_preserves_scroll() {
        let mut app = App::new();
        let entry = app
            .build_config_entries()
            .into_iter()
            .find(|entry| entry.action == ConfigAction::ShowSummary)
            .expect("summary entry");
        let detail_lines = app.build_config_detail_lines(Some(&entry));
        assert!(detail_lines.len() > 18);

        app.handle_config_command(String::new());
        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        assert_eq!(
            app.simple_split_focus(DetailSurface::ConfigSelector),
            SplitPaneFocus::Detail
        );
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(app.split_detail_scroll(DetailSurface::ConfigSelector), 8);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(
            app.detail_fullscreen_scroll(DetailSurface::ConfigSelector),
            16
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert_eq!(app.split_detail_scroll(DetailSurface::ConfigSelector), 16);
    }

    #[tokio::test]
    async fn remote_skill_browser_detail_focus_pages_split_detail() {
        let mut app = App::new();
        app.remote_skill_browser
            .selector
            .set_items(vec![RemoteSkillEntry {
                source_label: "EdgeCrab".into(),
                trust_level: "trusted".into(),
                name: "long-detail".into(),
                identifier: "edgecrab://skills/long-detail".into(),
                description: (0..24)
                    .map(|idx| format!("line {idx:02} wraps through the detail pane"))
                    .collect::<Vec<_>>()
                    .join("\n"),
                origin: "https://example.com/skills/long-detail".into(),
                tags: vec!["tui".into(), "audit".into()],
                installed_name: None,
                action: RemoteSkillAction::Install,
                search_text: "long detail tui audit".into(),
            }]);
        app.remote_skill_browser.selector.active = true;

        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        assert_eq!(
            app.simple_split_focus(DetailSurface::RemoteSkillBrowser),
            SplitPaneFocus::Detail
        );
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(
            app.split_detail_scroll(DetailSurface::RemoteSkillBrowser),
            8
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(
            app.detail_fullscreen_scroll(DetailSurface::RemoteSkillBrowser),
            16
        );

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert_eq!(
            app.split_detail_scroll(DetailSurface::RemoteSkillBrowser),
            16
        );
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn cheap_model_command_updates_agent_and_config() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        app.handle_set_cheap_model("copilot/gpt-4.1-mini".into());

        let smart_routing = rt.block_on(agent.smart_routing_config());
        assert!(smart_routing.enabled);
        assert_eq!(smart_routing.cheap_model, "vscode-copilot/gpt-4.1-mini");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.model.smart_routing.enabled);
        assert_eq!(
            cfg.model.smart_routing.cheap_model,
            "vscode-copilot/gpt-4.1-mini"
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_reference_selector_tracks_saved_selection() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        app.handle_save_moa_reference_selection(vec![
            "anthropic/claude-opus-4.6".into(),
            "openai/gpt-4.1".into(),
        ]);

        let moa = rt.block_on(agent.moa_config());
        assert!(moa.enabled);
        assert_eq!(moa.reference_models.len(), 2);
        assert!(
            moa.reference_models
                .iter()
                .any(|model| model == "openai/gpt-4.1")
        );

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.moa.enabled);
        assert_eq!(cfg.moa.reference_models.len(), 2);

        app.open_moa_reference_selector();
        assert!(app.moa_reference_selector.active);
        assert_eq!(app.moa_reference_selected.len(), 2);
        assert_eq!(
            app.moa_reference_selector_mode,
            MoaReferenceSelectorMode::EditRoster
        );

        app.open_moa_add_expert_selector();
        assert!(app.moa_reference_selector.active);
        assert_eq!(
            app.moa_reference_selector_mode,
            MoaReferenceSelectorMode::AddExpert
        );
        assert!(
            app.moa_reference_selector
                .items
                .iter()
                .all(|entry| !app.moa_reference_selected.contains(&entry.display))
        );

        app.open_moa_remove_expert_selector();
        assert!(app.moa_reference_selector.active);
        assert_eq!(
            app.moa_reference_selector_mode,
            MoaReferenceSelectorMode::RemoveExpert
        );
        assert_eq!(app.moa_reference_selector.items.len(), 2);
        assert!(
            app.moa_reference_selector
                .items
                .iter()
                .all(|entry| app.moa_reference_selected.contains(&entry.display))
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_off_command_updates_agent_and_config() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        app.handle_set_moa_enabled(false);

        let moa = rt.block_on(agent.moa_config());
        assert!(!moa.enabled);
        assert!(!moa.reference_models.is_empty());

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(!cfg.moa.enabled);
        assert!(!cfg.moa.reference_models.is_empty());

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_aggregator_command_enables_feature_and_normalizes_model() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        app.handle_set_moa_enabled(false);
        app.handle_set_moa_aggregator("copilot/gpt-4.1-mini".into());

        let moa = rt.block_on(agent.moa_config());
        assert!(moa.enabled);
        assert_eq!(moa.aggregator_model, "vscode-copilot/gpt-4.1-mini");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.moa.enabled);
        assert_eq!(cfg.moa.aggregator_model, "vscode-copilot/gpt-4.1-mini");

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_on_repairs_whitelist_and_persists_toolset_filters() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        let enabled_toolsets = vec!["web".to_string(), "terminal".to_string()];
        persist_toolset_filters_to_config(Some(&enabled_toolsets), None).expect("persist toolsets");
        rt.block_on(agent.set_toolset_filters(enabled_toolsets, Vec::new()));
        app.handle_set_moa_enabled(true);

        let (enabled_toolsets, disabled_toolsets) = rt.block_on(agent.toolset_filters());
        assert!(enabled_toolsets.iter().any(|entry| entry == "moa"));
        assert!(disabled_toolsets.is_empty());

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(
            cfg.tools
                .enabled_toolsets
                .unwrap_or_default()
                .iter()
                .any(|entry| entry == "moa")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_on_removes_literal_disabled_toolset_block() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        let disabled_toolsets = vec!["moa".to_string()];
        persist_toolset_filters_to_config(None, Some(&disabled_toolsets))
            .expect("persist toolsets");
        rt.block_on(agent.set_toolset_filters(Vec::new(), disabled_toolsets));
        app.handle_set_moa_enabled(true);

        let (_, disabled_toolsets) = rt.block_on(agent.toolset_filters());
        assert!(disabled_toolsets.is_empty());

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(
            cfg.tools
                .disabled_toolsets
                .unwrap_or_default()
                .iter()
                .all(|entry| entry != "moa")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_on_reports_alias_blocker_when_toolset_policy_still_hides_tool() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        let disabled_toolsets = vec!["safe".to_string()];
        persist_toolset_filters_to_config(None, Some(&disabled_toolsets))
            .expect("persist toolsets");
        rt.block_on(agent.set_toolset_filters(Vec::new(), disabled_toolsets));
        app.handle_set_moa_enabled(true);

        let availability = app.current_moa_availability();
        assert!(availability.feature_enabled);
        assert!(!availability.toolset_enabled);
        assert!(
            app.output
                .last()
                .expect("moa feedback")
                .text
                .contains("tools.disabled_toolsets` still blocks `moa` via safe")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn persist_tool_filters_round_trip() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let enabled_toolsets = vec!["core".to_string()];
        let disabled_toolsets = vec!["web".to_string()];
        let enabled_tools = vec!["browser_click".to_string()];
        let disabled_tools = vec!["terminal".to_string()];

        persist_tool_filters_to_config(
            Some(&enabled_toolsets),
            Some(&disabled_toolsets),
            Some(&enabled_tools),
            Some(&disabled_tools),
        )
        .expect("persist tool filters");

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert_eq!(
            cfg.tools.enabled_toolsets.unwrap_or_default(),
            enabled_toolsets
        );
        assert_eq!(
            cfg.tools.disabled_toolsets.unwrap_or_default(),
            disabled_toolsets
        );
        assert_eq!(cfg.tools.enabled_tools.unwrap_or_default(), enabled_tools);
        assert_eq!(cfg.tools.disabled_tools.unwrap_or_default(), disabled_tools);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn tool_manager_toggle_persists_explicit_tool_disable() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent.clone());

        app.open_tool_manager(ToolManagerMode::All);
        app.tool_manager_scope = ToolManagerScope::Tools;
        let _ = app.refresh_tool_manager_entries();
        let tool_idx = app
            .tool_manager
            .items
            .iter()
            .position(|entry| {
                entry.kind == ToolManagerItemKind::Tool && entry.name.as_str() == "terminal"
            })
            .expect("terminal tool");
        app.tool_manager.selected = app
            .tool_manager
            .filtered
            .iter()
            .position(|candidate| *candidate == tool_idx)
            .expect("terminal selected");

        app.toggle_tool_manager_selected();

        let (_, _, _, disabled_tools) = rt.block_on(agent.tool_filters());
        assert!(disabled_tools.iter().any(|entry| entry == "terminal"));

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(
            cfg.tools
                .disabled_tools
                .unwrap_or_default()
                .iter()
                .any(|entry| entry == "terminal")
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn tool_manager_shows_plugin_tools_and_tracks_enable_disable() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        write_script_plugin(dir.path(), "demo-script", "demo_plugin_tool");

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let agent = mock_agent();
        drop(_enter);
        app.set_agent(agent);

        app.refresh_agent_plugin_runtime()
            .expect("refresh plugin runtime");
        app.open_tool_manager(ToolManagerMode::All);
        app.tool_manager_scope = ToolManagerScope::Tools;
        let _ = app.refresh_tool_manager_entries();

        let plugin_tool = app
            .tool_manager
            .items
            .iter()
            .find(|entry| entry.name == "demo_plugin_tool")
            .expect("plugin tool visible");
        assert_eq!(plugin_tool.kind, ToolManagerItemKind::Tool);
        assert_eq!(plugin_tool.toolset, "plugins");
        assert!(plugin_tool.dynamic, "plugin tool should be dynamic");
        assert!(plugin_tool.exposed, "enabled plugin tool should be exposed");

        app.toggle_named_plugin("demo-script", None);
        assert!(
            app.tool_manager
                .items
                .iter()
                .all(|entry| entry.name != "demo_plugin_tool"),
            "disabled plugin tool should be removed from the live tool manager inventory"
        );

        app.toggle_named_plugin("demo-script", None);
        assert!(
            app.tool_manager
                .items
                .iter()
                .any(|entry| entry.name == "demo_plugin_tool"),
            "re-enabled plugin tool should return to the live tool manager inventory"
        );

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    #[serial_test::serial(edgecrab_home_env)]
    fn moa_reset_uses_current_chat_model_as_safe_baseline() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }

        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let _enter = rt.enter();
        let mut app = App::new();
        let provider: Arc<dyn edgequake_llm::LLMProvider> =
            Arc::new(edgequake_llm::MockProvider::new());
        let agent = Arc::new(
            edgecrab_core::AgentBuilder::new("copilot/gpt-5-mini")
                .provider(provider)
                .build()
                .expect("build agent"),
        );
        drop(_enter);
        app.set_agent(agent.clone());

        app.handle_reset_moa_config();

        let moa = rt.block_on(agent.moa_config());
        assert!(moa.enabled);
        assert_eq!(moa.aggregator_model, "vscode-copilot/gpt-5-mini");
        assert_eq!(moa.reference_models, vec!["vscode-copilot/gpt-5-mini"]);

        let cfg = edgecrab_core::AppConfig::load().expect("load config");
        assert!(cfg.moa.enabled);
        assert_eq!(cfg.moa.aggregator_model, "vscode-copilot/gpt-5-mini");
        assert_eq!(cfg.moa.reference_models, vec!["vscode-copilot/gpt-5-mini"]);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[test]
    fn key_binding_match_supports_case_and_spacing_normalization() {
        let key = event::KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
        assert!(key_matches_binding(&key, " Ctrl + B "));
    }

    #[test]
    fn key_binding_match_rejects_wrong_modifier_or_unknown_binding() {
        let key = event::KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
        assert!(!key_matches_binding(&key, "alt+b"));
        assert!(!key_matches_binding(&key, "hyper+b"));
    }

    #[test]
    fn key_binding_match_supports_function_keys() {
        let key = event::KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE);
        assert!(key_matches_binding(&key, "f6"));
    }

    #[tokio::test]
    async fn app_regular_input() {
        let mut app = App::new();
        app.process_input("explain this code");
        assert!(
            app.output
                .iter()
                .any(|l| l.text.contains("explain this code"))
        );
    }

    #[tokio::test]
    async fn first_token_transitions_awaiting_state_to_streaming() {
        let mut app = App::new();
        app.streaming_enabled = true;
        app.display_state = DisplayState::AwaitingFirstToken {
            frame: 0,
            started: Instant::now(),
        };
        app.response_tx
            .send(AgentResponse::Token("hello".into()))
            .expect("send token");
        app.check_responses();
        assert!(matches!(app.display_state, DisplayState::Streaming { .. }));
    }

    #[test]
    fn recover_latest_assistant_turn_uses_most_recent_non_empty_assistant_message() {
        let mut recovered_message = edgecrab_types::Message::assistant("Recovered reply");
        recovered_message.reasoning = Some("Recovered reasoning".into());
        let messages = vec![
            edgecrab_types::Message::user("hello"),
            edgecrab_types::Message::assistant(""),
            recovered_message,
        ];

        let recovered =
            recover_latest_assistant_turn(&messages).expect("recover latest assistant turn");
        assert_eq!(recovered.text, "Recovered reply");
        assert_eq!(recovered.reasoning.as_deref(), Some("Recovered reasoning"));
    }

    #[tokio::test]
    async fn forward_agent_stream_recovers_reply_when_agent_finishes_without_terminal_event() {
        let agent = mock_agent();
        agent.inject_assistant_context("Recovered reply").await;
        let (chunk_tx, chunk_rx) =
            tokio::sync::mpsc::unbounded_channel::<edgecrab_core::agent::StreamEvent>();
        let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel();
        let hook_registry = Arc::new(edgecrab_gateway::hooks::HookRegistry::new());

        let bridge = tokio::spawn(forward_agent_stream_to_tui(
            agent,
            chunk_rx,
            tokio::spawn(async { Ok::<(), edgecrab_types::AgentError>(()) }),
            response_tx,
            hook_registry,
        ));

        // Keep the stream sender alive to simulate a pump that would otherwise
        // wait forever for a terminal event on the stream channel.
        let first = response_rx.recv().await.expect("recovered token");
        let second = response_rx.recv().await.expect("recovered done");
        drop(chunk_tx);
        bridge.await.expect("bridge task completes");

        match first {
            AgentResponse::Token(text) => assert_eq!(text, "Recovered reply"),
            _ => panic!("expected recovered token"),
        }
        assert!(matches!(second, AgentResponse::Done));
    }

    #[tokio::test]
    async fn forward_agent_stream_does_not_duplicate_tokens_when_done_is_missing() {
        let agent = mock_agent();
        agent.inject_assistant_context("hello").await;
        let (chunk_tx, chunk_rx) =
            tokio::sync::mpsc::unbounded_channel::<edgecrab_core::agent::StreamEvent>();
        let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel();
        let hook_registry = Arc::new(edgecrab_gateway::hooks::HookRegistry::new());

        chunk_tx
            .send(edgecrab_core::agent::StreamEvent::Token("hel".into()))
            .expect("send partial token");

        let bridge = tokio::spawn(forward_agent_stream_to_tui(
            agent,
            chunk_rx,
            tokio::spawn(async { Ok::<(), edgecrab_types::AgentError>(()) }),
            response_tx,
            hook_registry,
        ));

        let first = response_rx.recv().await.expect("forwarded token");
        let second = response_rx.recv().await.expect("synthetic done");
        drop(chunk_tx);
        bridge.await.expect("bridge task completes");

        match first {
            AgentResponse::Token(text) => assert_eq!(text, "hel"),
            _ => panic!("expected forwarded token"),
        }
        assert!(matches!(second, AgentResponse::Done));
        assert!(response_rx.try_recv().is_err(), "unexpected extra response");
    }

    #[tokio::test]
    async fn hidden_live_token_display_buffers_until_done() {
        let mut app = App::new();
        app.streaming_enabled = false;
        app.live_token_display_enabled = false;
        app.is_processing = true;
        app.display_state = DisplayState::AwaitingFirstToken {
            frame: 0,
            started: Instant::now(),
        };

        app.response_tx
            .send(AgentResponse::Token("hel".into()))
            .expect("send token");
        app.response_tx
            .send(AgentResponse::Token("lo".into()))
            .expect("send token");
        app.check_responses();

        assert_eq!(app.buffered_assistant_output, "hello");
        assert!(
            app.output
                .iter()
                .all(|line| line.role != OutputRole::Assistant || line.text != "hello")
        );

        app.response_tx
            .send(AgentResponse::Done)
            .expect("send done");
        app.check_responses();

        assert!(app.buffered_assistant_output.is_empty());
        assert!(
            app.output
                .iter()
                .any(|line| line.role == OutputRole::Assistant && line.text == "hello")
        );
    }

    #[tokio::test]
    async fn hidden_live_token_display_flushes_before_tool_exec() {
        let mut app = App::new();
        app.streaming_enabled = false;
        app.live_token_display_enabled = false;
        app.is_processing = true;
        app.display_state = DisplayState::AwaitingFirstToken {
            frame: 0,
            started: Instant::now(),
        };

        app.response_tx
            .send(AgentResponse::Token("plan".into()))
            .expect("send token");
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "tool-1".into(),
                name: "terminal".into(),
                args_json: "{\"command\":\"echo ok\"}".into(),
            })
            .expect("send tool exec");
        app.check_responses();

        assert!(app.buffered_assistant_output.is_empty());
        assert!(
            app.output
                .iter()
                .any(|line| line.role == OutputRole::Assistant && line.text == "plan")
        );
        assert!(app.pending_tool_lines.contains_key("tool-1"));
    }

    #[tokio::test]
    async fn tool_done_updates_matching_placeholder_even_when_completions_arrive_out_of_order() {
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::All;
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_1".into(),
                name: "read_file".into(),
                args_json: r#"{"path":"a.rs"}"#.into(),
            })
            .expect("send tool exec 1");
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_2".into(),
                name: "terminal".into(),
                args_json: r#"{"command":"echo hi"}"#.into(),
            })
            .expect("send tool exec 2");
        app.response_tx
            .send(AgentResponse::ToolDone {
                tool_call_id: "call_2".into(),
                name: "terminal".into(),
                args_json: r#"{"command":"echo hi"}"#.into(),
                result_preview: Some("stdout: hi".into()),
                duration_ms: 12,
                is_error: false,
            })
            .expect("send terminal done");
        app.check_responses();

        assert_eq!(app.output.len(), 2);
        let first = line_spans_text(&app.output[0]);
        let second = line_spans_text(&app.output[1]);
        assert!(first.contains("read"), "unexpected first line: {first}");
        assert!(first.contains("···"), "unexpected first line: {first}");
        assert!(second.contains("$"), "unexpected second line: {second}");
        assert!(
            second.contains("echo hi"),
            "unexpected second line: {second}"
        );
        assert!(second.contains("12ms"), "unexpected second line: {second}");
    }

    #[tokio::test]
    async fn tool_progress_updates_active_placeholder_and_status_detail() {
        let mut app = App::new();
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_moa".into(),
                name: "moa".into(),
                args_json: r#"{"user_prompt":"ping"}"#.into(),
            })
            .expect("send moa tool exec");
        app.response_tx
            .send(AgentResponse::ToolProgress {
                tool_call_id: "call_moa".into(),
                name: "moa".into(),
                message: "2/3 experts completed; trying aggregator fallback".into(),
            })
            .expect("send moa progress");
        app.check_responses();

        assert_eq!(app.output.len(), 1);
        let running = line_spans_text(&app.output[0]);
        assert!(running.contains("2/3 experts completed"), "got: {running}");
        match &app.display_state {
            DisplayState::ToolExec { detail, .. } => {
                assert_eq!(
                    detail.as_deref(),
                    Some("2/3 experts completed; trying aggregator fallback")
                );
            }
            other => panic!("expected ToolExec state, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn tool_done_retargets_status_to_remaining_parallel_tool() {
        let mut app = App::new();
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_read".into(),
                name: "read_file".into(),
                args_json: r#"{"path":"src/main.rs"}"#.into(),
            })
            .expect("send read tool exec");
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_todo".into(),
                name: "manage_todo_list".into(),
                args_json:
                    r#"{"items":[{"id":1,"title":"Audit","status":"in-progress"}],"merge":true}"#
                        .into(),
            })
            .expect("send todo tool exec");
        app.response_tx
            .send(AgentResponse::ToolProgress {
                tool_call_id: "call_read".into(),
                name: "read_file".into(),
                message: "line window 1-120 loaded".into(),
            })
            .expect("send read progress");
        app.response_tx
            .send(AgentResponse::ToolDone {
                tool_call_id: "call_todo".into(),
                name: "manage_todo_list".into(),
                args_json:
                    r#"{"items":[{"id":1,"title":"Audit","status":"in-progress"}],"merge":true}"#
                        .into(),
                result_preview: Some("1/3 done, 1 in progress".into()),
                duration_ms: 6,
                is_error: false,
            })
            .expect("send todo done");
        app.check_responses();

        match &app.display_state {
            DisplayState::ToolExec {
                tool_call_id,
                name,
                detail,
                ..
            } => {
                assert_eq!(tool_call_id, "call_read");
                assert_eq!(name, "read_file");
                assert_eq!(detail.as_deref(), Some("line window 1-120 loaded"));
            }
            other => panic!("expected ToolExec state, got {other:?}"),
        }
        assert_eq!(app.in_flight_tool_count, 1);
        assert!(app.active_tools.contains_key("call_read"));
        assert!(!app.active_tools.contains_key("call_todo"));
    }

    #[tokio::test]
    async fn tool_progress_off_hides_transcript_lines() {
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::Off;
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_hidden".into(),
                name: "read_file".into(),
                args_json: r#"{"path":"src/main.rs"}"#.into(),
            })
            .expect("send tool exec");
        app.response_tx
            .send(AgentResponse::ToolDone {
                tool_call_id: "call_hidden".into(),
                name: "read_file".into(),
                args_json: r#"{"path":"src/main.rs"}"#.into(),
                result_preview: Some("loaded".into()),
                duration_ms: 8,
                is_error: false,
            })
            .expect("send tool done");
        app.check_responses();

        assert!(
            app.output.is_empty(),
            "unexpected output: {:?}",
            app.output.len()
        );
        assert!(app.pending_tool_lines.is_empty());
        assert!(app.hidden_tool_calls.is_empty());
    }

    #[tokio::test]
    async fn tool_progress_new_suppresses_repeated_signatures() {
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::New;
        for tool_call_id in ["call_1", "call_2"] {
            app.response_tx
                .send(AgentResponse::ToolExec {
                    tool_call_id: tool_call_id.into(),
                    name: "read_file".into(),
                    args_json: r#"{"path":"src/main.rs"}"#.into(),
                })
                .expect("send tool exec");
            app.response_tx
                .send(AgentResponse::ToolDone {
                    tool_call_id: tool_call_id.into(),
                    name: "read_file".into(),
                    args_json: r#"{"path":"src/main.rs"}"#.into(),
                    result_preview: Some("loaded".into()),
                    duration_ms: 5,
                    is_error: false,
                })
                .expect("send tool done");
        }
        app.check_responses();

        assert_eq!(app.output.len(), 1);
        let rendered = line_spans_text(&app.output[0]);
        assert!(
            rendered.contains("src/main.rs"),
            "unexpected line: {rendered}"
        );
    }

    #[tokio::test]
    async fn tool_progress_verbose_adds_detail_lines() {
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::Verbose;
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_verbose".into(),
                name: "terminal".into(),
                args_json: r#"{"command":"cargo test -p edgecrab-cli"}"#.into(),
            })
            .expect("send tool exec");
        app.response_tx
            .send(AgentResponse::ToolDone {
                tool_call_id: "call_verbose".into(),
                name: "terminal".into(),
                args_json: r#"{"command":"cargo test -p edgecrab-cli"}"#.into(),
                result_preview: Some("test result: ok".into()),
                duration_ms: 42,
                is_error: false,
            })
            .expect("send tool done");
        app.check_responses();

        assert_eq!(app.output.len(), 3);
        // Verbose line now shows the command value directly (no "args:" prefix).
        assert!(line_spans_text(&app.output[1]).contains("cargo"));
        assert!(line_spans_text(&app.output[2]).contains("test result: ok"));
    }

    #[tokio::test]
    async fn tool_progress_verbose_renders_todo_plan_board() {
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::Verbose;
        app.response_tx
            .send(AgentResponse::ToolExec {
                tool_call_id: "call_todo_verbose".into(),
                name: "manage_todo_list".into(),
                args_json: r#"{"items":[{"id":1,"title":"Audit Hermes display","status":"completed"},{"id":2,"title":"Improve todo renderer","status":"in-progress"},{"id":3,"title":"Reassess UX","status":"not-started"}],"merge":true}"#.into(),
            })
            .expect("send todo tool exec");
        app.response_tx
            .send(AgentResponse::ToolDone {
                tool_call_id: "call_todo_verbose".into(),
                name: "manage_todo_list".into(),
                args_json: r#"{"items":[{"id":1,"title":"Audit Hermes display","status":"completed"},{"id":2,"title":"Improve todo renderer","status":"in-progress"},{"id":3,"title":"Reassess UX","status":"not-started"}],"merge":true}"#.into(),
                result_preview: Some("1/3 done, 1 in progress".into()),
                duration_ms: 19,
                is_error: false,
            })
            .expect("send todo tool done");
        app.check_responses();

        assert_eq!(app.output.len(), 5);
        assert!(line_spans_text(&app.output[0]).contains("plan"));
        assert!(line_spans_text(&app.output[1]).contains("1/3 done, 1 in progress"));
        assert!(line_spans_text(&app.output[2]).contains("[x]"));
        assert!(line_spans_text(&app.output[3]).contains("[>]"));
        assert!(line_spans_text(&app.output[4]).contains("[ ]"));
    }

    #[tokio::test]
    #[serial_test::serial(edgecrab_home_env)]
    async fn cycle_tool_progress_mode_follows_hermes_order() {
        let _guard = crate::gateway_catalog::lock_test_env();
        let dir = tempfile::tempdir().expect("tempdir");
        unsafe {
            std::env::set_var("EDGECRAB_HOME", dir.path());
        }
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::Off;
        assert!(app.cycle_tool_progress_mode().contains("NEW"));
        assert_eq!(app.tool_progress_mode, ToolProgressMode::New);
        assert!(app.cycle_tool_progress_mode().contains("ALL"));
        assert_eq!(app.tool_progress_mode, ToolProgressMode::All);
        assert!(app.cycle_tool_progress_mode().contains("VERBOSE"));
        assert_eq!(app.tool_progress_mode, ToolProgressMode::Verbose);
        assert!(app.cycle_tool_progress_mode().contains("OFF"));
        assert_eq!(app.tool_progress_mode, ToolProgressMode::Off);

        unsafe {
            std::env::remove_var("EDGECRAB_HOME");
        }
    }

    #[tokio::test]
    async fn explicit_tool_progress_command_sets_and_reports_status() {
        let mut app = App::new();
        app.tool_progress_mode = ToolProgressMode::All;
        assert!(
            app.handle_tool_progress_command("status".into())
                .contains("ALL")
        );
        assert!(
            app.handle_tool_progress_command("verbose".into())
                .contains("VERBOSE")
        );
        assert_eq!(app.tool_progress_mode, ToolProgressMode::Verbose);
        assert!(
            app.handle_tool_progress_command("bogus".into())
                .contains("Usage: /verbose")
        );
    }

    #[tokio::test]
    async fn app_handle_ctrl_c_exits_on_empty() {
        let mut app = App::new();
        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('c'),
            KeyModifiers::CONTROL,
        ));
        assert!(app.should_exit());
    }

    #[test]
    fn progressive_keyboard_flags_request_full_csi_u_path() {
        let flags = progressive_keyboard_flags();
        assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS));
    }

    #[tokio::test]
    async fn history_push_and_recall() {
        let mut app = App::new();
        app.input_history.clear();
        app.history_pos = 0;
        app.history_loaded = true; // Prevent ensure_history_loaded from reloading from disk
        app.push_history("/help");
        app.push_history("/status");
        assert_eq!(app.input_history.len(), 2);
        assert_eq!(app.history_pos, 2);

        // Navigate up
        app.history_up();
        assert_eq!(app.history_pos, 1);
        app.history_up();
        assert_eq!(app.history_pos, 0);
        // Should not go below 0
        app.history_up();
        assert_eq!(app.history_pos, 0);

        // Navigate down
        app.history_down();
        assert_eq!(app.history_pos, 1);
        app.history_down();
        assert_eq!(app.history_pos, 2); // back to "new input"
    }

    #[tokio::test]
    async fn history_dedup_consecutive() {
        let mut app = App::new();
        app.input_history.clear();
        app.history_pos = 0;
        app.history_loaded = true; // Prevent ensure_history_loaded from reloading from disk
        app.push_history("/help");
        app.push_history("/help");
        assert_eq!(app.input_history.len(), 1);
    }

    #[tokio::test]
    async fn completion_candidates_prefix() {
        let app = App::new();
        let candidates: Vec<_> = app
            .all_command_names
            .iter()
            .filter(|c| c.starts_with("/he"))
            .cloned()
            .collect();
        assert!(candidates.contains(&"/help".to_string()));
    }

    #[tokio::test]
    async fn completion_fuzzy_match() {
        let app = App::new();
        // Test fuzzy match for a typo
        let scored: Vec<_> = app
            .all_command_names
            .iter()
            .map(|cmd| (cmd.clone(), strsim::jaro_winkler("/hepl", cmd)))
            .filter(|(_, score)| *score > 0.7)
            .collect();
        assert!(
            !scored.is_empty(),
            "fuzzy match should find /help for /hepl"
        );
    }

    #[tokio::test]
    async fn format_tokens_display() {
        assert_eq!(format_tokens(500), "500");
        assert_eq!(format_tokens(1500), "1.5k");
        assert_eq!(format_tokens(1_500_000), "1.5M");
    }

    // ── FP39: words_estimate ────────────────────────────────────────────────

    #[test]
    fn words_estimate_basics() {
        // Under 20 chars → 0
        assert_eq!(words_estimate(0), 0);
        assert_eq!(words_estimate(15), 0);
        // 45 chars ≈ 10 words (bucketed to 10)
        assert_eq!(words_estimate(45), 10);
        // 450 chars ≈ 100 words
        assert_eq!(words_estimate(450), 100);
        // 4500 chars ≈ 1000 words
        assert_eq!(words_estimate(4500), 1000);
        // Bucketing: 55 chars = 12.2 → buckets to 10
        assert_eq!(words_estimate(55), 10);
        // 90 chars = 20 words
        assert_eq!(words_estimate(90), 20);
    }

    // ── FP43: format_elapsed_hint ───────────────────────────────────────────

    #[test]
    fn format_elapsed_hint_threshold() {
        use std::time::Duration;
        // Below threshold → empty
        assert_eq!(format_elapsed_hint(Duration::from_secs(3), 5), "");
        assert_eq!(format_elapsed_hint(Duration::from_secs(4), 5), "");
        // At threshold → shows
        assert_eq!(format_elapsed_hint(Duration::from_secs(5), 5), "  5s");
        assert_eq!(format_elapsed_hint(Duration::from_secs(60), 5), "  60s");
        // Different threshold
        assert_eq!(format_elapsed_hint(Duration::from_secs(2), 3), "");
        assert_eq!(format_elapsed_hint(Duration::from_secs(3), 3), "  3s");
    }

    // ── FP40: extract_streaming_section ────────────────────────────────────

    #[test]
    fn extract_streaming_section_detects_headings() {
        let mut section: Option<String> = None;
        // Level 2 heading at start of token after newline
        extract_streaming_section("\n## Competitive Landscape\nsome text", &mut section);
        assert_eq!(section.as_deref(), Some("Competitive Landscape"));

        // Level 1 heading
        let mut s2: Option<String> = None;
        extract_streaming_section("\n# Introduction\n", &mut s2);
        assert_eq!(s2.as_deref(), Some("Introduction"));

        // No heading → no change
        let mut s3: Option<String> = Some("Previous".to_string());
        extract_streaming_section("just prose text", &mut s3);
        assert_eq!(s3.as_deref(), Some("Previous"));

        // Heading at very start of first token (no leading newline)
        let mut s4: Option<String> = None;
        extract_streaming_section("## Market Analysis\n", &mut s4);
        assert_eq!(s4.as_deref(), Some("Market Analysis"));

        // Truncation at 30 chars
        let mut s5: Option<String> = None;
        let long_title = format!("\n## {}\n", "A".repeat(40));
        extract_streaming_section(&long_title, &mut s5);
        assert_eq!(s5.as_ref().map(|s| s.len()), Some(30));
    }

    #[tokio::test]
    async fn ghost_hint_works() {
        let mut app = App::new();
        app.push_history("/model copilot/gpt-4.1-mini");
        // Simulate typing "/mod"
        for ch in "/mod".chars() {
            app.textarea.insert_char(ch);
        }
        let hint = app.ghost_hint();
        assert!(hint.is_some(), "ghost hint should be available");
        assert!(
            hint.unwrap().starts_with("el"),
            "hint should complete /model..."
        );
    }

    // ── Tab completion: argument preservation ────────────────────────────────

    /// Pressing Tab when the user has typed "/hel some-query" should NOT wipe
    /// "some-query" — it should only complete/replace the command token "/hel".
    #[tokio::test]
    async fn completion_preserves_argument_tail() {
        let mut app = App::new();
        // Make sure "/help" is a known command so we can trigger the exact path.
        assert!(
            app.all_command_names.contains(&"/help".to_string()),
            "/help must be a registered command for this test"
        );

        // Type a partial command (NOT an exact alias) plus an argument.
        // "/hel" is a partial, not an exact alias — "/help" is the full command.
        for ch in "/hel some-query".chars() {
            app.textarea.insert_char(ch);
        }
        // update_completion must find "/help" as a prefix candidate.
        app.update_completion();
        assert!(app.completion.active, "completion should activate for /hel");

        // Force the selected candidate to /help.
        let help_idx = app
            .completion
            .candidates
            .iter()
            .position(|(c, _)| c == "/help");
        assert!(help_idx.is_some(), "/help must appear in candidates");
        app.completion.selected = help_idx.unwrap();

        // Accept — the textarea should now be "/help some-query" (arg preserved).
        app.accept_completion();
        let result = app.textarea_text();
        assert!(
            result.starts_with("/help"),
            "accepted command should be /help, got: {result}"
        );
        assert!(
            result.contains("some-query"),
            "argument 'some-query' must be preserved, got: {result}"
        );
    }

    /// When the command token exactly names a known command AND args follow,
    /// update_completion must suppress the overlay (nothing to complete).
    #[tokio::test]
    async fn completion_suppressed_when_exact_command_with_args() {
        let mut app = App::new();
        assert!(app.all_command_names.contains(&"/help".to_string()));

        for ch in "/help advanced-topic".chars() {
            app.textarea.insert_char(ch);
        }
        app.update_completion();
        assert!(
            !app.completion.active,
            "completion must not activate when full command + args are typed"
        );
    }

    // ── Tab key: ghost hint acceptance priority ──────────────────────────────

    /// Tab at end-of-line with an available ghost hint should accept the hint
    /// (fish-shell behaviour) instead of opening the completion overlay.
    #[tokio::test]
    async fn tab_key_accepts_ghost_hint_first() {
        let mut app = App::new();
        app.push_history("explain this code in detail");

        // Type enough of the entry to produce a ghost hint.
        for ch in "explain".chars() {
            app.textarea.insert_char(ch);
        }
        assert!(
            app.ghost_hint().is_some(),
            "ghost hint must exist before the test is meaningful"
        );

        // Fire the Tab key.
        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

        let text = app.textarea_text();
        assert_eq!(
            text, "explain this code in detail",
            "Tab should have accepted the full ghost hint"
        );
        // Completion overlay must NOT have been activated.
        assert!(
            !app.completion.active,
            "completion overlay must stay closed when ghost hint was accepted"
        );
    }

    /// Tab with no ghost hint present should open the command completion overlay.
    #[tokio::test]
    async fn tab_key_opens_completion_when_no_ghost() {
        let mut app = App::new();
        app.input_history.clear(); // ensure no ghost hint

        // Type a partial command with no history match.
        for ch in "/he".chars() {
            app.textarea.insert_char(ch);
        }
        assert!(app.ghost_hint().is_none(), "precondition: no ghost hint");

        app.handle_key_event(event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
        assert!(
            app.completion.active,
            "Tab without ghost hint must open completion overlay for /he"
        );
    }

    #[tokio::test]
    async fn shift_enter_enters_compose_insert_and_inserts_newline() {
        let mut app = App::new();
        app.textarea.insert_str("hello");

        app.handle_key_event(event::KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);
        assert_eq!(app.textarea_text(), "hello\n");
    }

    #[tokio::test]
    async fn ctrl_j_is_multiline_fallback_when_terminal_reports_it_distinctly() {
        let mut app = App::new();
        app.textarea.insert_str("alpha");

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('j'),
            KeyModifiers::CONTROL,
        ));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);
        assert_eq!(app.textarea_text(), "alpha\n");
    }

    #[tokio::test]
    async fn ctrl_o_is_terminal_safe_multiline_fallback() {
        let mut app = App::new();
        app.textarea.insert_str("alpha");

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('o'),
            KeyModifiers::CONTROL,
        ));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);
        assert_eq!(app.textarea_text(), "alpha\n");
    }

    #[tokio::test]
    async fn compose_insert_escape_switches_to_normal_and_back_to_inline() {
        let mut app = App::new();
        app.open_compose_editor(false);
        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert_eq!(app.editor_mode, InputEditorMode::ComposeNormal);

        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert_eq!(app.editor_mode, InputEditorMode::Inline);
    }

    #[tokio::test]
    async fn compose_long_wrapped_line_expands_input_height() {
        let mut app = App::new();
        app.open_compose_editor(false);
        app.textarea.insert_str(
            "this is a deliberately long compose line that should wrap across multiple visual rows \
             instead of disappearing off-screen",
        );

        assert_eq!(app.textarea.wrap_mode(), WrapMode::WordOrGlyph);
        assert!(
            app.input_area_height_for_area(Rect::new(0, 0, 28, 20)) > 5,
            "wrapped compose input should grow beyond the compose minimum height"
        );
    }

    #[tokio::test]
    async fn compose_page_down_scrolls_editor_not_output() {
        let mut app = App::new();
        app.open_compose_editor(false);
        app.textarea = TextArea::from((0..24).map(|i| format!("line {i:02}")));
        app.textarea.set_wrap_mode(WrapMode::WordOrGlyph);
        app.apply_textarea_editor_style();
        app.textarea.move_cursor(CursorMove::Top);

        let area = Rect::new(0, 0, 40, 6);
        let mut buffer = ratatui::buffer::Buffer::empty(area);
        ratatui::widgets::Widget::render(&app.textarea, area, &mut buffer);

        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert_eq!(app.scroll_offset, 0);
        assert!(
            app.textarea.cursor().0 > 0,
            "PageDown in compose mode should move the editor viewport/cursor"
        );
    }

    #[test]
    fn ctrl_b_and_ctrl_f_normalize_to_page_keys() {
        let page_up = normalize_fallback_paging_key(event::KeyEvent::new(
            KeyCode::Char('b'),
            KeyModifiers::CONTROL,
        ));
        assert_eq!(page_up.code, KeyCode::PageUp);
        assert_eq!(page_up.modifiers, KeyModifiers::NONE);

        let page_down = normalize_fallback_paging_key(event::KeyEvent::new(
            KeyCode::Char('f'),
            KeyModifiers::CONTROL,
        ));
        assert_eq!(page_down.code, KeyCode::PageDown);
        assert_eq!(page_down.modifiers, KeyModifiers::NONE);
    }

    #[test]
    fn raw_control_chars_normalize_to_shortcuts() {
        let ctrl_c = normalize_terminal_control_key(event::KeyEvent::new(
            KeyCode::Char('\u{3}'),
            KeyModifiers::NONE,
        ));
        assert_eq!(ctrl_c.code, KeyCode::Char('c'));
        assert_eq!(ctrl_c.modifiers, KeyModifiers::CONTROL);

        let ctrl_f = normalize_terminal_control_key(event::KeyEvent::new(
            KeyCode::Char('\u{6}'),
            KeyModifiers::NONE,
        ));
        assert_eq!(ctrl_f.code, KeyCode::Char('f'));
        assert_eq!(ctrl_f.modifiers, KeyModifiers::CONTROL);

        let ctrl_o = normalize_terminal_control_key(event::KeyEvent::new(
            KeyCode::Char('\u{f}'),
            KeyModifiers::NONE,
        ));
        assert_eq!(ctrl_o.code, KeyCode::Char('o'));
        assert_eq!(ctrl_o.modifiers, KeyModifiers::CONTROL);
    }

    #[test]
    fn raw_newline_normalizes_to_enter_not_ctrl_j() {
        let enter = normalize_terminal_control_key(event::KeyEvent::new(
            KeyCode::Char('\n'),
            KeyModifiers::NONE,
        ));
        assert_eq!(enter.code, KeyCode::Enter);
        assert_eq!(enter.modifiers, KeyModifiers::NONE);
    }

    #[tokio::test]
    async fn raw_newline_submits_inline_input_instead_of_opening_compose() {
        let mut app = App::new();
        app.textarea.insert_str("hello");

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('\n'),
            KeyModifiers::NONE,
        ));

        assert_eq!(app.editor_mode, InputEditorMode::Inline);
        assert_eq!(app.textarea_text(), "");
        assert_eq!(
            app.output.first().map(|line| line.text.as_str()),
            Some("> hello")
        );
    }

    #[tokio::test]
    async fn ctrl_c_clears_waiting_state_for_active_request() {
        let mut app = App::new();
        app.is_processing = true;
        app.display_state = DisplayState::AwaitingFirstToken {
            frame: 0,
            started: Instant::now(),
        };
        let request = tokio::spawn(async {
            tokio::time::sleep(Duration::from_secs(60)).await;
        });
        app.active_request_abort = Some(request.abort_handle());

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('c'),
            KeyModifiers::CONTROL,
        ));

        tokio::task::yield_now().await;
        assert!(
            !app.is_processing,
            "Ctrl+C should end the active request state"
        );
        assert!(
            app.active_request_abort.is_none(),
            "Ctrl+C should clear the active request abort handle"
        );
        assert!(
            matches!(app.display_state, DisplayState::Idle),
            "Ctrl+C should leave the TUI idle instead of stuck waiting"
        );
    }

    #[tokio::test]
    async fn ctrl_f_scrolls_document_overlay_when_page_down_is_unavailable() {
        let mut app = App::new();
        let body = (0..80)
            .map(|i| format!("line {i:02}"))
            .collect::<Vec<_>>()
            .join("\n");
        app.open_document_overlay("Doc", "fallback paging", body, "≡", Color::White);

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('f'),
            KeyModifiers::CONTROL,
        ));

        let scroll = app
            .document_overlay
            .as_ref()
            .map(|overlay| overlay.scroll)
            .unwrap_or_default();
        assert!(scroll > 0, "Ctrl+F should page the document overlay down");
    }

    #[tokio::test]
    async fn ctrl_b_and_ctrl_f_scroll_output_without_direct_page_keys() {
        let mut app = App::new();
        app.output_visual_rows = 120;
        app.output_area_height = 20;
        app.scroll_offset = 0;
        app.at_bottom = true;

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('b'),
            KeyModifiers::CONTROL,
        ));
        assert!(app.scroll_offset > 0, "Ctrl+B should page output upward");

        app.handle_key_event(event::KeyEvent::new(
            KeyCode::Char('f'),
            KeyModifiers::CONTROL,
        ));
        assert_eq!(app.scroll_offset, 0, "Ctrl+F should page output back down");
    }

    #[tokio::test]
    async fn app_startup_defers_nonessential_warmup_work() {
        let app = App::new();
        assert!(!app.history_loaded);
        assert!(!app.model_selector_seeded);
        assert!(!app.hook_registry_loaded);
        assert!(app.model_selector.items.is_empty());
    }

    #[test]
    fn loaded_hooks_report_includes_useful_empty_state_guidance() {
        let report = format_loaded_hooks_report(&[]);
        assert!(report.contains("No file-based hooks are currently loaded."));
        assert!(report.contains("agent:stop"));
        assert!(report.contains("/hooks reload"));
    }

    #[test]
    fn stop_hook_followup_prompt_uses_hidden_prefix() {
        let prompt = build_stop_hook_followup_prompt("tests still need to run");
        assert!(prompt.starts_with(STOP_HOOK_AUTO_PREFIX));
        assert!(prompt.contains("tests still need to run"));
    }

    #[tokio::test]
    async fn compose_normal_pending_operator_is_visible_in_title() {
        let mut app = App::new();
        app.open_compose_editor(false);
        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));

        assert_eq!(app.input_panel_title(">"), " Compose NORMAL [d] ");
        assert!(app.compose_normal_hint().contains("[d]"));
    }

    #[tokio::test]
    async fn compose_normal_x_deletes_character() {
        let mut app = App::new();
        app.textarea.insert_str("hello");
        app.open_compose_editor(false);
        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeNormal);
        assert_eq!(app.textarea_text(), "ello");
    }

    #[tokio::test]
    async fn compose_normal_c_alias_changes_to_end_of_line() {
        let mut app = App::new();
        app.textarea.insert_str("hello world");
        app.open_compose_editor(false);
        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);
        assert_eq!(app.textarea_text(), "");
    }

    #[tokio::test]
    async fn compose_normal_s_substitutes_character() {
        let mut app = App::new();
        app.textarea.insert_str("hello");
        app.open_compose_editor(false);
        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);
        assert_eq!(app.textarea_text(), "ello");
    }

    #[tokio::test]
    async fn compose_normal_o_opens_new_line_and_enters_insert() {
        let mut app = App::new();
        app.textarea.insert_str("hello");
        app.open_compose_editor(false);
        app.handle_key_event(event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        app.handle_key_event(event::KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));

        assert_eq!(app.editor_mode, InputEditorMode::ComposeInsert);
        assert_eq!(app.textarea_text(), "hello\n");
    }

    #[test]
    fn completion_visible_window_tracks_selected_row() {
        let completion = CompletionState {
            candidates: (0..20)
                .map(|i| (format!("/cmd{i}"), String::new()))
                .collect(),
            selected: 12,
            active: true,
            arg_start: 0,
        };

        let (start, end) = completion.visible_window(8);
        assert_eq!((start, end), (5, 13));
        assert!(
            (start..end).contains(&completion.selected),
            "selected row must stay inside the rendered window"
        );
    }

    #[tokio::test]
    async fn completion_page_down_moves_beyond_first_viewport() {
        let mut app = App::new();
        app.completion = CompletionState {
            candidates: (0..20)
                .map(|i| (format!("/cmd{i}"), String::new()))
                .collect(),
            selected: 0,
            active: true,
            arg_start: 0,
        };

        app.handle_key_event(event::KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));

        assert_eq!(app.completion.selected, 8);
        let (start, end) = app.completion.visible_window(8);
        assert_eq!((start, end), (1, 9));
        assert!(
            (start..end).contains(&app.completion.selected),
            "page navigation must move the popup viewport with the selection"
        );
    }

    // ── ghost_hint_next_word ─────────────────────────────────────────────────

    #[tokio::test]
    async fn ghost_hint_next_word_returns_first_word() {
        let mut app = App::new();
        app.push_history("/model copilot/gpt-4.1-mini");
        for ch in "/model".chars() {
            app.textarea.insert_char(ch);
        }
        // Ghost hint should be " copilot/gpt-4.1-mini"
        let word = app.ghost_hint_next_word();
        assert!(word.is_some(), "next-word hint must be available");
        let w = word.unwrap();
        // Should contain the space + the model name token (not the full hint).
        assert!(
            w.contains("copilot"),
            "next word should be 'copilot/gpt-4.1-mini', got: {w}"
        );
        // Must NOT span all the way to the end when there is only one token.
        assert!(!w.is_empty(), "next word must not be empty");
    }

    /// Alt+Right at EOL with a ghost hint should accept only the next word.
    #[tokio::test]
    async fn alt_right_accepts_next_ghost_word() {
        let mut app = App::new();
        app.push_history("explain this code in detail");
        for ch in "explain".chars() {
            app.textarea.insert_char(ch);
        }
        assert!(app.ghost_hint_next_word().is_some(), "precondition");

        app.handle_key_event(event::KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));

        let text = app.textarea_text();
        // Should have accepted " this" (one word) but NOT the full hint.
        assert!(text.starts_with("explain"), "prefix must be preserved");
        assert!(
            text.len() > "explain".len(),
            "at least one word should have been added"
        );
        assert!(
            text != "explain this code in detail",
            "full hint must NOT have been accepted; only one word"
        );
    }

    #[tokio::test]
    async fn mouse_mode_toggle_sets_pending_request() {
        let mut app = App::new();
        // Seed the state explicitly so terminal-capability auto-detection does
        // not make this toggle-behavior test environment-dependent.
        app.mouse_capture_enabled = true;
        assert!(app.mouse_capture_enabled);
        // Turning on when already on is a no-op — no pending request.
        app.handle_mouse_mode("on".into());
        assert!(app.mouse_capture_enabled);
        assert_eq!(app.take_mouse_capture_request(), None);
        app.handle_mouse_mode("off".into());
        assert!(!app.mouse_capture_enabled);
        assert_eq!(app.take_mouse_capture_request(), Some(false));
        // Now turning on should queue a pending enable.
        app.handle_mouse_mode("on".into());
        assert!(app.mouse_capture_enabled);
        assert_eq!(app.take_mouse_capture_request(), Some(true));
    }

    #[tokio::test]
    async fn remove_reasoning_output_block_keeps_stream_alignment() {
        let mut app = App::new();
        app.output.push(OutputLine {
            text: "Thinking\nstep 1".into(),
            role: OutputRole::Reasoning,
            prebuilt_spans: None,
            rendered: None,
            plain_rendered: None,
        });
        app.output.push(OutputLine {
            text: "answer".into(),
            role: OutputRole::Assistant,
            prebuilt_spans: None,
            rendered: None,
            plain_rendered: None,
        });
        app.reasoning_line = Some(0);
        app.streaming_line = Some(1);

        app.remove_reasoning_output_block();

        assert!(app.reasoning_line.is_none());
        assert_eq!(app.streaming_line, Some(0));
        assert_eq!(app.output.len(), 1);
        assert!(matches!(app.output[0].role, OutputRole::Assistant));
    }

    // ─── image paste detection ──────────────────────────────────────────

    #[test]
    fn is_image_path_recognises_common_extensions() {
        assert!(App::is_image_path("/home/user/photo.png"));
        assert!(App::is_image_path("/tmp/screen.jpg"));
        assert!(App::is_image_path("/tmp/screen.jpeg"));
        assert!(App::is_image_path("/tmp/anim.gif"));
        assert!(App::is_image_path("/tmp/art.webp"));
        assert!(App::is_image_path("/tmp/scan.bmp"));
        assert!(App::is_image_path("/tmp/scan.tiff"));
        assert!(App::is_image_path("/tmp/scan.tif"));
        assert!(App::is_image_path("/tmp/next.avif"));
    }

    #[test]
    fn is_image_path_case_insensitive() {
        assert!(App::is_image_path("/tmp/PHOTO.PNG"));
        assert!(App::is_image_path("/tmp/Photo.Jpg"));
    }

    #[test]
    fn is_image_path_rejects_non_image() {
        assert!(!App::is_image_path("hello world"));
        assert!(!App::is_image_path("/tmp/archive.zip"));
        assert!(!App::is_image_path("/tmp/doc.pdf"));
        assert!(!App::is_image_path("no extension"));
        assert!(!App::is_image_path(""));
    }

    #[tokio::test]
    async fn handle_paste_text_goes_to_textarea() {
        let mut app = App::new();
        app.handle_paste("hello world".into());
        assert_eq!(app.textarea_text(), "hello world");
        assert!(app.pending_images.is_empty());
    }

    #[test]
    fn selector_marker_uses_explicit_selected_chevron() {
        let selected = selector_marker(true, Color::Cyan, Some(Color::Black));
        let idle = selector_marker(false, Color::Cyan, Some(Color::Black));

        assert_eq!(selected.content, "▶ ");
        assert_eq!(idle.content, "  ");
    }

    // ── Subcommand / argument completion ────────────────────────────────────

    /// command_arg_hints should return non-empty slices for known commands.
    #[test]
    fn command_arg_hints_returns_subcommands_for_session() {
        let hints = App::command_arg_hints("session");
        assert!(!hints.is_empty(), "session should have subcommand hints");
        let names: Vec<&str> = hints.iter().map(|(n, _)| *n).collect();
        assert!(
            names.contains(&"inspect"),
            "session hints must include 'inspect'"
        );
        assert!(
            names.contains(&"debug"),
            "session hints must include 'debug'"
        );
        assert!(
            names.contains(&"browse"),
            "session hints must include 'browse'"
        );
        assert!(
            names.contains(&"search"),
            "session hints must include 'search'"
        );
        assert!(
            names.contains(&"switch"),
            "session hints must include 'switch'"
        );
        assert!(names.contains(&"new"), "session hints must include 'new'");
    }

    /// `/sessions` should expose archive-centric hints.
    #[test]
    fn command_arg_hints_sessions_returns_archive_actions() {
        let hints = App::command_arg_hints("sessions");
        let names: Vec<&str> = hints.iter().map(|(name, _)| *name).collect();
        assert!(names.contains(&"list"));
        assert!(names.contains(&"browse"));
        assert!(names.contains(&"search"));
        assert!(names.contains(&"switch"));
    }

    /// command_arg_hints for an unknown token should return an empty slice.
    #[test]
    fn command_arg_hints_unknown_command_returns_empty() {
        assert!(App::command_arg_hints("nonexistent_cmd").is_empty());
    }

    #[test]
    fn command_arg_hints_streaming_alias_matches() {
        let hints = App::command_arg_hints("stream");
        assert_eq!(hints, App::command_arg_hints("streaming"));
        let names: Vec<&str> = hints.iter().map(|(name, _)| *name).collect();
        assert!(names.contains(&"toggle"));
        assert!(names.contains(&"status"));
    }

    /// After typing "/sessions " (with trailing space) update_completion should
    /// populate candidates with subcommands and set arg_start > 0.
    #[tokio::test]
    async fn update_completion_arg_context_populates_subcommands() {
        let mut app = App::new();
        // "/sessions" must be a registered command for archive arg-context.
        assert!(
            app.all_command_names
                .iter()
                .any(|c| c == "/session" || c == "/sessions"),
            "/sessions must be a registered command"
        );
        for ch in "/sessions ".chars() {
            app.textarea.insert_char(ch);
        }
        app.update_completion();
        assert!(
            app.completion.active,
            "completion must activate in arg context"
        );
        assert!(
            app.completion.arg_start > 0,
            "arg_start must be set to a non-zero byte offset"
        );
        let candidate_names: Vec<&str> = app
            .completion
            .candidates
            .iter()
            .map(|(n, _)| n.as_str())
            .collect();
        assert!(
            candidate_names.contains(&"list"),
            "must have 'list' candidate"
        );
        assert!(
            candidate_names.contains(&"switch"),
            "must have 'switch' candidate"
        );
    }

    /// Prefix "/sessions sw" should narrow candidates to those starting with "sw".
    #[tokio::test]
    async fn update_completion_arg_prefix_filters_candidates() {
        let mut app = App::new();
        for ch in "/sessions sw".chars() {
            app.textarea.insert_char(ch);
        }
        app.update_completion();
        assert!(app.completion.active);
        let names: Vec<&str> = app
            .completion
            .candidates
            .iter()
            .map(|(n, _)| n.as_str())
            .collect();
        assert!(
            names.iter().all(|n| n.starts_with("sw")),
            "all candidates should match prefix 'sw', got: {names:?}"
        );
        assert!(
            names.contains(&"switch"),
            "switch must be in narrowed candidates"
        );
    }

    /// Accepting a subcommand must produce "/sessions switch " (cmd preserved, arg appended).
    #[tokio::test]
    async fn accept_completion_arg_context_preserves_command_prefix() {
        let mut app = App::new();
        for ch in "/sessions ".chars() {
            app.textarea.insert_char(ch);
        }
        app.update_completion();
        assert!(app.completion.active, "completion must be active");

        // Select "switch"
        let switch_idx = app
            .completion
            .candidates
            .iter()
            .position(|(n, _)| n == "switch");
        assert!(switch_idx.is_some(), "'switch' must be among candidates");
        app.completion.selected = switch_idx.unwrap();

        app.accept_completion();
        let result = app.textarea_text();
        assert!(
            result.starts_with("/sessions "),
            "command prefix '/sessions ' must be preserved, got: {result}"
        );
        assert!(
            result.ends_with("switch "),
            "result must end with 'switch ', got: {result}"
        );
    }

    /// Fuzzy match: "/sessions lisst" should still surface "list" when score ≥ 0.65.
    #[tokio::test]
    async fn update_completion_arg_fuzzy_typo_matches_list() {
        let mut app = App::new();
        for ch in "/sessions lisst".chars() {
            app.textarea.insert_char(ch);
        }
        app.update_completion();
        // May or may not be active depending on fuzzy score — just check it
        // doesn't hard-crash and that if active it contains 'list'.
        if app.completion.active {
            let names: Vec<&str> = app
                .completion
                .candidates
                .iter()
                .map(|(n, _)| n.as_str())
                .collect();
            assert!(
                names.contains(&"list"),
                "fuzzy match for 'lisst' should find 'list', got: {names:?}"
            );
        }
        // (If not active, that's also acceptable — the typo was too far off.)
    }

    /// textarea_set_text should replace current content atomically.
    #[tokio::test]
    async fn textarea_set_text_replaces_content() {
        let mut app = App::new();
        for ch in "old content".chars() {
            app.textarea.insert_char(ch);
        }
        app.textarea_set_text("new content");
        assert_eq!(app.textarea_text(), "new content");
    }

    /// textarea_set_text with empty string should clear the textarea.
    #[tokio::test]
    async fn textarea_set_text_empty_clears_textarea() {
        let mut app = App::new();
        for ch in "something".chars() {
            app.textarea.insert_char(ch);
        }
        app.textarea_set_text("");
        assert_eq!(app.textarea_text(), "");
    }

    #[tokio::test]
    async fn model_selector_refresh_opens_without_blocking_overlay() {
        let mut app = App::new();

        app.refresh_model_selector_catalog();

        assert!(app.model_selector.active);
        assert!(app.model_selector_refresh_in_flight);
        assert!(
            !matches!(app.display_state, DisplayState::BgOp { .. }),
            "model selector should stay interactive while discovery runs"
        );
        assert!(
            app.model_selector
                .items
                .iter()
                .any(|entry| entry.provider == "bedrock"),
            "bedrock must be visible in the selector immediately from the static catalog"
        );
    }

    #[test]
    fn models_inventory_report_lists_bedrock() {
        let report = build_models_inventory_report(
            &ModelCatalog::grouped_catalog(),
            "bedrock/amazon.nova-lite-v1:0",
            "",
        );

        assert!(report.contains("bedrock"));
        assert!(report.contains("AWS Bedrock"));
        assert!(report.contains("live discovery"));
    }

    #[test]
    fn provider_models_report_explains_feature_gated_bedrock() {
        let report = build_provider_models_report(
            &ProviderModels {
                provider: "bedrock".into(),
                models: vec!["amazon.nova-lite-v1:0".into()],
                source: DiscoverySource::Static,
            },
            DiscoveryAvailability::FeatureGated("bedrock-model-discovery"),
            "bedrock/amazon.nova-lite-v1:0",
        );

        assert!(report.contains("bedrock-model-discovery"));
        assert!(report.contains("AWS Bedrock"));
        assert!(report.contains("amazon.nova-lite-v1:0 *"));
    }

    #[tokio::test(flavor = "multi_thread")]
    #[ignore] // Skip - requires TLS/TrustStore setup for bedrock
    async fn bedrock_provider_factory_is_wired() {
        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            ProviderFactory::create_llm_provider("bedrock", "amazon.nova-lite-v1:0")
        }));

        match result {
            Ok(Ok(_)) => {}
            Ok(Err(err)) => {
                assert!(
                    !err.to_string().contains("Unknown LLM provider"),
                    "bedrock must be recognized by the runtime provider factory: {err}"
                );
            }
            Err(panic) => {
                let panic_text = panic
                    .downcast_ref::<String>()
                    .map(String::as_str)
                    .or_else(|| panic.downcast_ref::<&'static str>().copied())
                    .unwrap_or("<non-string panic>");
                assert!(
                    panic_text.contains("TrustStore configured to enable native roots"),
                    "bedrock provider wiring should not panic unexpectedly: {panic_text}"
                );
            }
        }
    }
}
