//! Interactive mode TUI components
//!
//! Provides high-level interactive components for the oxi terminal interface:
//! - Session selector (navigate/switch/create/delete sessions)
//! - Model selector (choose AI model grouped by provider)
//! - Footer (status bar with model, session, tokens, cost)
//! - Login dialog (API key entry with provider selection)
//! - Diff viewer (show edit diffs with color highlighting)
//! - Bash execution display (streaming output, timer, cancel)
//! - Assistant message rendering (thinking blocks, tool calls, markdown)
//! - Tool execution rendering (args, results, images, status)
//! - Summary message rendering (compaction, branch)

use serde::{Deserialize, Serialize};

use rand::RngCore;

/// Content block types for assistant messages
#[derive(Debug, Clone)]
pub enum AssistantContentBlock {
    /// Text content with optional markdown
    Text { text: String },
    /// Thinking/reasoning block (collapsible)
    Thinking { thinking: String },
    /// Tool call invocation
    ToolCall {
        id: String,
        name: String,
        arguments: String,
    },
}

/// Assistant message data structure
#[derive(Debug, Clone)]
pub struct AssistantMessage {
    pub content: Vec<AssistantContentBlock>,
    pub stop_reason: Option<StopReason>,
    pub error_message: Option<String>,
}

/// Why the assistant message stopped
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StopReason {
    EndTurn,
    MaxTokens,
    StopSequence,
    Aborted,
    Error,
}

impl StopReason {
    pub fn as_str(&self) -> &'static str {
        match self {
            StopReason::EndTurn => "end_turn",
            StopReason::MaxTokens => "max_tokens",
            StopReason::StopSequence => "stop_sequence",
            StopReason::Aborted => "aborted",
            StopReason::Error => "error",
        }
    }
}

impl AssistantMessage {
    pub fn new() -> Self {
        Self {
            content: Vec::new(),
            stop_reason: None,
            error_message: None,
        }
    }

    /// Add a text block
    pub fn add_text(&mut self, text: impl Into<String>) {
        self.content
            .push(AssistantContentBlock::Text { text: text.into() });
    }

    /// Add a thinking block
    pub fn add_thinking(&mut self, thinking: impl Into<String>) {
        self.content.push(AssistantContentBlock::Thinking {
            thinking: thinking.into(),
        });
    }

    /// Add a tool call block
    pub fn add_tool_call(
        &mut self,
        id: impl Into<String>,
        name: impl Into<String>,
        arguments: impl Into<String>,
    ) {
        self.content.push(AssistantContentBlock::ToolCall {
            id: id.into(),
            name: name.into(),
            arguments: arguments.into(),
        });
    }

    /// Check if message has any visible content
    pub fn has_visible_content(&self) -> bool {
        self.content.iter().any(|c| match c {
            AssistantContentBlock::Text { text } => !text.trim().is_empty(),
            AssistantContentBlock::Thinking { thinking } => !thinking.trim().is_empty(),
            AssistantContentBlock::ToolCall { .. } => false,
        })
    }

    /// Check if message has tool calls
    pub fn has_tool_calls(&self) -> bool {
        self.content
            .iter()
            .any(|c| matches!(c, AssistantContentBlock::ToolCall { .. }))
    }
}

impl Default for AssistantMessage {
    fn default() -> Self {
        Self::new()
    }
}

/// Options for rendering assistant messages
#[derive(Debug, Clone)]
pub struct AssistantMessageRenderOptions {
    /// Hide thinking blocks and show a label instead
    pub hide_thinking: bool,
    /// Label to show when thinking is hidden
    pub hidden_thinking_label: String,
    /// Use OSC 133 prompt escape codes for terminal integration
    pub use_osc133: bool,
}

impl Default for AssistantMessageRenderOptions {
    fn default() -> Self {
        Self {
            hide_thinking: false,
            hidden_thinking_label: "Thinking...".to_string(),
            use_osc133: false,
        }
    }
}

/// Assistant message renderer
pub struct AssistantMessageRenderer {
    options: AssistantMessageRenderOptions,
}

impl AssistantMessageRenderer {
    pub fn new(options: AssistantMessageRenderOptions) -> Self {
        Self { options }
    }

    /// Set hide thinking option
    pub fn with_hide_thinking(mut self, hide: bool) -> Self {
        self.options.hide_thinking = hide;
        self
    }

    /// Set hidden thinking label
    pub fn with_hidden_thinking_label(mut self, label: impl Into<String>) -> Self {
        self.options.hidden_thinking_label = label.into();
        self
    }

    /// Enable OSC 133 escape codes for terminal integration
    pub fn with_osc133(mut self, enable: bool) -> Self {
        self.options.use_osc133 = enable;
        self
    }

    /// Render an assistant message to a string
    pub fn render(&self, message: &AssistantMessage) -> String {
        let mut output = String::new();

        // OSC 133 zone start
        if self.options.use_osc133 {
            output.push_str("\x1b]133;A\x07");
        }

        let mut has_visible_content = false;
        let visible_count = message
            .content
            .iter()
            .filter(|c| match c {
                AssistantContentBlock::Text { text } => !text.trim().is_empty(),
                AssistantContentBlock::Thinking { thinking } => !thinking.trim().is_empty(),
                AssistantContentBlock::ToolCall { .. } => false,
            })
            .count();

        let mut visible_idx = 0;

        for block in &message.content {
            match block {
                AssistantContentBlock::Text { text } if !text.trim().is_empty() => {
                    if has_visible_content {
                        output.push('\n');
                    }
                    visible_idx += 1;
                    has_visible_content = true;
                    output.push_str(&render_markdown(text.trim()));
                    if visible_idx < visible_count {
                        output.push('\n');
                    }
                }
                AssistantContentBlock::Thinking { thinking } if !thinking.trim().is_empty() => {
                    if has_visible_content {
                        output.push('\n');
                    }
                    visible_idx += 1;
                    has_visible_content = true;

                    if self.options.hide_thinking {
                        // Show static thinking label (italic, dimmed)
                        output.push_str(&format!(
                            "\x1b[2m\x1b[3m{}\x1b[0m",
                            self.options.hidden_thinking_label
                        ));
                    } else {
                        // Show thinking content (italic, dimmed)
                        let rendered = render_markdown(thinking.trim());
                        output.push_str(&format!("\x1b[2m\x1b[3m{}\x1b[0m", rendered));
                    }

                    if visible_idx < visible_count {
                        output.push('\n');
                    }
                }
                _ => {}
            }
        }

        // Handle stop reasons (only if no tool calls)
        if !message.has_tool_calls() {
            if let Some(ref reason) = message.stop_reason {
                if has_visible_content {
                    output.push('\n');
                }
                match reason {
                    StopReason::Aborted => {
                        let msg = message
                            .error_message
                            .as_ref()
                            .filter(|m| *m != "Request was aborted")
                            .cloned()
                            .unwrap_or_else(|| "Operation aborted".to_string());
                        output.push_str(&format!("\x1b[31m{}\x1b[0m", msg));
                    }
                    StopReason::Error => {
                        let msg = message
                            .error_message
                            .as_ref()
                            .cloned()
                            .unwrap_or_else(|| "Unknown error".to_string());
                        output.push_str(&format!("\x1b[31mError: {}\x1b[0m", msg));
                    }
                    _ => {}
                }
            }
        }

        // OSC 133 zone end
        if self.options.use_osc133 {
            output.push_str("\x1b]133;B\x07\x1b]133;C\x07");
        }

        output
    }
}

impl Default for AssistantMessageRenderer {
    fn default() -> Self {
        Self::new(AssistantMessageRenderOptions::default())
    }
}

/// Simple markdown rendering (bold, italic, code)
fn render_markdown(text: &str) -> String {
    let mut output = String::new();
    let mut chars = text.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '`' {
            // Inline code
            let mut code = String::new();
            while let Some(&next) = chars.peek() {
                if next == '`' {
                    chars.next();
                    // Check for code block
                    if chars.peek() == Some(&'`') {
                        chars.next();
                        // Triple backtick - code block
                        let mut block = String::new();
                        while let Some(ch) = chars.next() {
                            if ch == '`' {
                                if chars.clone().take(2).collect::<String>() == "``" {
                                    chars.nth(2);
                                    break;
                                }
                                block.push(ch);
                            } else {
                                block.push(ch);
                            }
                        }
                        output.push_str(&format!("\x1b[36m{}\x1b[0m", block.trim()));
                        break;
                    }
                    break;
                } else {
                    code.push(chars.next().unwrap());
                }
            }
            if !code.is_empty() {
                output.push_str(&format!("\x1b[33m{}\x1b[0m", code));
            }
        } else if c == '*' && chars.peek() == Some(&'*') {
            // Bold
            chars.next();
            let mut bold = String::new();
            while let Some(&next) = chars.peek() {
                if next == '*' && chars.clone().nth(1) == Some('*') {
                    chars.next();
                    chars.next();
                    break;
                }
                bold.push(chars.next().unwrap());
            }
            output.push_str(&format!("\x1b[1m{}\x1b[0m", bold));
        } else if c == '_' {
            // Italic
            let mut italic = String::new();
            while let Some(&next) = chars.peek() {
                if next == '_' {
                    chars.next();
                    break;
                }
                italic.push(chars.next().unwrap());
            }
            output.push_str(&format!("\x1b[3m{}\x1b[0m", italic));
        } else {
            output.push(c);
        }
    }

    output
}

/// Session info for display in session selector
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
    pub id: String,
    pub name: String,
    pub created_at: String,
    pub message_count: usize,
    pub model: Option<String>,
    pub parent_id: Option<String>,
}

/// Session selector state
#[derive(Debug, Clone)]
pub struct SessionSelector {
    pub sessions: Vec<SessionInfo>,
    pub selected_index: usize,
    pub filter: String,
    pub scroll_offset: usize,
    pub visible_height: usize,
}

impl SessionSelector {
    pub fn new(sessions: Vec<SessionInfo>) -> Self {
        Self {
            sessions,
            selected_index: 0,
            filter: String::new(),
            scroll_offset: 0,
            visible_height: 20,
        }
    }

    /// Get filtered sessions matching the current filter
    pub fn filtered_sessions(&self) -> Vec<&SessionInfo> {
        if self.filter.is_empty() {
            self.sessions.iter().collect()
        } else {
            let filter_lower = self.filter.to_lowercase();
            self.sessions
                .iter()
                .filter(|s| {
                    s.name.to_lowercase().contains(&filter_lower)
                        || s.id.to_lowercase().contains(&filter_lower)
                })
                .collect()
        }
    }

    /// Move selection up
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }

    /// Move selection down
    pub fn move_down(&mut self) {
        let max = self.filtered_sessions().len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
            self.adjust_scroll();
        }
    }

    /// Get currently selected session
    pub fn selected(&self) -> Option<&SessionInfo> {
        self.filtered_sessions()
            .into_iter()
            .nth(self.selected_index)
    }

    /// Update filter text
    pub fn set_filter(&mut self, filter: String) {
        self.filter = filter;
        self.selected_index = 0;
        self.scroll_offset = 0;
    }

    fn adjust_scroll(&mut self) {
        if self.selected_index < self.scroll_offset {
            self.scroll_offset = self.selected_index;
        } else if self.selected_index >= self.scroll_offset + self.visible_height {
            self.scroll_offset = self.selected_index - self.visible_height + 1;
        }
    }

    /// Render the session selector as a string
    pub fn render(&self) -> String {
        let mut output = String::new();
        output.push_str(&format!("{}\n", "─".repeat(60)));
        output.push_str("Sessions (↑↓ navigate, Enter select, n new, d delete, / filter)\n");
        output.push_str(&format!("{}\n", "─".repeat(60)));

        if !self.filter.is_empty() {
            output.push_str(&format!("Filter: {}\n", self.filter));
        }

        let filtered: Vec<_> = self.filtered_sessions();
        for (i, session) in filtered.iter().enumerate() {
            let marker = if i == self.selected_index { "▶" } else { " " };
            let branch = if session.parent_id.is_some() {
                "├─ "
            } else {
                "  "
            };
            let name = if session.name.is_empty() {
                &session.id[..8.min(session.id.len())]
            } else {
                &session.name
            };
            output.push_str(&format!(
                "{} {}{:<30} {} msg:{} model:{}\n",
                marker,
                branch,
                name,
                &session.created_at[..10.min(session.created_at.len())],
                session.message_count,
                session.model.as_deref().unwrap_or("-"),
            ));
        }

        if filtered.is_empty() {
            output.push_str("  (no sessions)\n");
        }

        output
    }
}

/// Model info for model selector
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
    pub id: String,
    pub name: String,
    pub provider: String,
    pub supports_vision: bool,
    pub supports_tools: bool,
    pub supports_thinking: bool,
    pub context_window: usize,
}

/// Model selector state
#[derive(Debug, Clone)]
pub struct ModelSelector {
    pub models: Vec<ModelInfo>,
    pub selected_index: usize,
    pub filter: String,
    pub grouped: bool,
}

impl ModelSelector {
    pub fn new(models: Vec<ModelInfo>) -> Self {
        let mut models = models;
        models.sort_by(|a, b| a.provider.cmp(&b.provider).then(a.name.cmp(&b.name)));
        Self {
            models,
            selected_index: 0,
            filter: String::new(),
            grouped: true,
        }
    }

    /// Get filtered models
    pub fn filtered_models(&self) -> Vec<&ModelInfo> {
        if self.filter.is_empty() {
            self.models.iter().collect()
        } else {
            let filter_lower = self.filter.to_lowercase();
            self.models
                .iter()
                .filter(|m| {
                    m.name.to_lowercase().contains(&filter_lower)
                        || m.id.to_lowercase().contains(&filter_lower)
                        || m.provider.to_lowercase().contains(&filter_lower)
                })
                .collect()
        }
    }

    /// Move selection up
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    /// Move selection down
    pub fn move_down(&mut self) {
        let max = self.filtered_models().len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
        }
    }

    /// Get currently selected model
    pub fn selected(&self) -> Option<&ModelInfo> {
        self.filtered_models().into_iter().nth(self.selected_index)
    }

    /// Render the model selector
    pub fn render(&self) -> String {
        let mut output = String::new();
        output.push_str(&format!("{}\n", "─".repeat(60)));
        output.push_str("Select Model (↑↓ navigate, Enter select, / filter)\n");
        output.push_str(&format!("{}\n", "─".repeat(60)));

        let filtered: Vec<_> = self.filtered_models();
        let mut last_provider = String::new();

        for (i, model) in filtered.iter().enumerate() {
            // Provider group header
            if self.grouped && model.provider != last_provider {
                last_provider = model.provider.clone();
                output.push_str(&format!("\n  {}\n", model.provider.to_uppercase()));
            }

            let marker = if i == self.selected_index { "▶" } else { " " };
            let vision = if model.supports_vision { "👁" } else { " " };
            let tools = if model.supports_tools { "🔧" } else { " " };
            let thinking = if model.supports_thinking { "💭" } else { " " };
            let ctx = format_bytes(model.context_window);

            output.push_str(&format!(
                " {} {} {}{}{} {:<30} ctx:{}\n",
                marker, model.id, vision, tools, thinking, model.name, ctx,
            ));
        }

        output
    }
}

/// Footer status bar data
#[derive(Debug, Clone, Default)]
pub struct FooterData {
    pub model_name: String,
    pub session_name: String,
    pub provider_name: String,
    pub input_tokens: usize,
    pub output_tokens: usize,
    pub total_cost: f64,
    pub is_thinking: bool,
    pub elapsed_seconds: Option<u64>,
}

impl FooterData {
    /// Render the footer as a single-line status bar
    pub fn render(&self, width: usize) -> String {
        let thinking = if self.is_thinking { "⏳" } else { "✓" };
        let tokens = if self.input_tokens > 0 || self.output_tokens > 0 {
            format!("tok:{}+{}", self.input_tokens, self.output_tokens)
        } else {
            String::new()
        };
        let cost = if self.total_cost > 0.0 {
            format!("${:.4}", self.total_cost)
        } else {
            String::new()
        };
        let elapsed = self
            .elapsed_seconds
            .map(|s| format!("{}m{}s", s / 60, s % 60))
            .unwrap_or_default();

        let left = format!("{} {} @ {}", thinking, self.model_name, self.provider_name);
        let right = format!("{} {} {}", tokens, cost, elapsed);

        let session_part = if !self.session_name.is_empty() {
            format!(" │ {}", self.session_name)
        } else {
            String::new()
        };

        // Pad to width
        let content_len = left.len() + session_part.len() + right.len() + 2;
        if content_len < width {
            let padding = width - content_len;
            format!(
                "{}{}{:>width$}",
                left,
                session_part,
                right,
                width = padding + right.len()
            )
        } else {
            format!("{}{} {}", left, session_part, right)
        }
    }
}

/// Login dialog state
#[derive(Debug, Clone)]
pub struct LoginDialog {
    pub providers: Vec<String>,
    pub selected_provider_index: usize,
    pub api_key: String,
    pub cursor_pos: usize,
    pub error_message: Option<String>,
    pub is_masked: bool,
    /// OAuth-specific state
    pub oauth_state: Option<OAuthState>,
    /// Callback URL being waited for
    pub pending_auth_url: Option<String>,
}

/// OAuth provider configuration
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OAuthProvider {
    Anthropic,
    OpenAI,
    GitHub,
    Google,
    Azure,
    /// Custom/provider-specific OAuth
    Custom {
        id: String,
        name: String,
    },
}

impl OAuthProvider {
    /// Get the provider ID string
    pub fn id(&self) -> &str {
        match self {
            OAuthProvider::Anthropic => "anthropic",
            OAuthProvider::OpenAI => "openai",
            OAuthProvider::GitHub => "github",
            OAuthProvider::Google => "google",
            OAuthProvider::Azure => "azure",
            OAuthProvider::Custom { id, .. } => id,
        }
    }

    /// Get the display name
    pub fn name(&self) -> &str {
        match self {
            OAuthProvider::Anthropic => "Anthropic",
            OAuthProvider::OpenAI => "OpenAI",
            OAuthProvider::GitHub => "GitHub",
            OAuthProvider::Google => "Google",
            OAuthProvider::Azure => "Azure",
            OAuthProvider::Custom { name, .. } => name,
        }
    }

    /// Get the default redirect port for this provider
    pub fn default_port(&self) -> u16 {
        match self {
            OAuthProvider::Anthropic => 8787,
            OAuthProvider::OpenAI => 8788,
            OAuthProvider::GitHub => 8789,
            OAuthProvider::Google => 8790,
            OAuthProvider::Azure => 8791,
            OAuthProvider::Custom { .. } => 8792,
        }
    }

    /// Parse provider from ID string
    pub fn from_id(id: &str) -> Option<Self> {
        match id.to_lowercase().as_str() {
            "anthropic" => Some(OAuthProvider::Anthropic),
            "openai" => Some(OAuthProvider::OpenAI),
            "github" | "github-copilot" => Some(OAuthProvider::GitHub),
            "google" => Some(OAuthProvider::Google),
            "azure" => Some(OAuthProvider::Azure),
            _ => None,
        }
    }
}

/// Internal OAuth state for the login flow
#[derive(Debug, Clone)]
pub struct OAuthState {
    pub provider: OAuthProvider,
    pub code_verifier: String,
    pub state: String,
    pub authorization_url: String,
    pub callback_port: u16,
}

/// Login state machine states
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoginState {
    /// Initial state - showing provider selection
    ProviderSelection,
    /// Waiting for user to enter API key
    ApiKey,
    /// Showing the OAuth authorization URL
    WaitingForUrl,
    /// Waiting for browser callback
    WaitingForCallback,
    /// Showing manual code input prompt
    ManualInput,
    /// Polling for device flow completion
    Polling,
    /// Authentication successful
    Success,
    /// Authentication failed with error message
    Error(String),
}

impl Default for LoginState {
    fn default() -> Self {
        LoginState::ProviderSelection
    }
}

impl LoginDialog {
    pub fn new(providers: Vec<String>) -> Self {
        Self {
            providers,
            selected_provider_index: 0,
            api_key: String::new(),
            cursor_pos: 0,
            error_message: None,
            is_masked: true,
            oauth_state: None,
            pending_auth_url: None,
        }
    }

    /// Create a new login dialog with OAuth support
    pub fn new_with_oauth() -> Self {
        Self::new(vec![
            "anthropic".to_string(),
            "openai".to_string(),
            "github".to_string(),
        ])
    }

    /// Get the current login state
    pub fn login_state(&self) -> LoginState {
        if self.error_message.is_some() {
            return LoginState::Error(self.error_message.clone().unwrap());
        }
        if self.oauth_state.is_some() {
            if self.pending_auth_url.is_some() {
                return LoginState::WaitingForCallback;
            }
            return LoginState::WaitingForUrl;
        }
        LoginState::ApiKey
    }

    /// Start an OAuth flow for the selected provider
    /// Returns the authorization URL to display
    pub fn start_oauth_flow(&mut self, provider: OAuthProvider) -> Result<String, String> {
        let port = provider.default_port();
        let code_verifier = generate_code_verifier();
        let state = generate_state_token();

        // Build authorization URL based on provider
        let auth_url = match &provider {
            OAuthProvider::Anthropic => {
                format!(
                    "https://auth.anthropic.com/oauth/authorize?response_type=code&client_id={}&redirect_uri=http%3A%2F%2Flocalhost%3A{}&code_challenge={}&code_challenge_method=S256&state={}",
                    "anthropic-oauth-client",
                    port,
                    derive_code_challenge_sync(&code_verifier),
                    state
                )
            }
            OAuthProvider::OpenAI => {
                format!(
                    "https://auth.openai.com/authorize?response_type=code&client_id={}&redirect_uri=http%3A%2F%2Flocalhost%3A{}&code_challenge={}&code_challenge_method=S256&state={}",
                    "openai-oauth-client",
                    port,
                    derive_code_challenge_sync(&code_verifier),
                    state
                )
            }
            OAuthProvider::GitHub => {
                // GitHub uses device flow, not authorization code
                format!(
                    "https://github.com/login/device/code?client_id={}&scope=read:user%20user:email",
                    "Iv1.placeholder_client_id"
                )
            }
            _ => {
                return Err(format!(
                    "OAuth not supported for provider: {}",
                    provider.name()
                ));
            }
        };

        let oauth_state = OAuthState {
            provider,
            code_verifier,
            state,
            authorization_url: auth_url.clone(),
            callback_port: port,
        };

        self.oauth_state = Some(oauth_state);
        self.pending_auth_url = Some(auth_url.clone());
        Ok(auth_url)
    }

    /// Open the authorization URL in the default browser
    pub fn open_auth_url(&self, url: &str) -> Result<(), String> {
        crate::oauth_server::open_browser(url)
            .map(|_child| ())
            .map_err(|e| format!("Failed to open browser: {}", e))
    }

    /// Start the OAuth callback server
    pub fn start_callback_server(
        port: u16,
    ) -> Result<crate::oauth_server::OAuthCallbackServer, String> {
        let server = crate::oauth_server::OAuthCallbackServer::new(port);
        Ok(server)
    }

    /// Handle the OAuth callback with code and state
    pub fn handle_oauth_callback(&mut self, code: String, state: String) -> Result<(), String> {
        if let Some(ref oauth_state) = self.oauth_state {
            // Verify state matches
            if oauth_state.state != state {
                return Err("State mismatch - possible CSRF attack".to_string());
            }
            // Store code for exchange
            self.api_key = code;
            self.pending_auth_url = None;
            Ok(())
        } else {
            Err("No OAuth flow in progress".to_string())
        }
    }

    /// Show manual code input interface
    pub fn show_manual_code_input(&mut self, message: &str) {
        self.error_message = None;
        // The message indicates what to show
        if let Some(ref auth_url) = self.pending_auth_url {
            eprintln!("\n{}", message);
            eprintln!("Authorization URL: {}", auth_url);
            eprintln!("Paste the code from the redirect URL here:\n");
        }
    }

    /// Parse a redirect URL to extract the authorization code
    pub fn parse_redirect_url(url: &str) -> Option<(String, String)> {
        // Parse URL like http://localhost:8787/callback?code=xxx&state=yyy
        if let Ok(parsed) = url::Url::parse(url) {
            let code = parsed
                .query_pairs()
                .find(|(k, _)| k == "code")
                .map(|(_, v)| v.to_string());
            let state = parsed
                .query_pairs()
                .find(|(k, _)| k == "state")
                .map(|(_, v)| v.to_string());
            if let (Some(code), Some(state)) = (code, state) {
                return Some((code, state));
            }
        }

        // Try simple parsing for just ?code=xxx&state=yyy
        let query = url.split('?').nth(1)?;
        let mut code = None;
        let mut state = None;
        for pair in query.split('&') {
            let (key, value) = pair.split_once('=')?;
            let decoded = urlencoding::decode(value).ok()?.to_string();
            match key {
                "code" => code = Some(decoded),
                "state" => state = Some(decoded),
                _ => {}
            }
        }
        Some((code?, state.unwrap_or_default()))
    }

    /// Complete the OAuth flow with the authorization code
    pub fn complete_oauth(&mut self, code: String) -> Result<(), String> {
        if let Some(ref _oauth_state) = self.oauth_state {
            // Store the code for exchange - the actual token exchange
            // would be done by the caller using oxi-ai's oauth module
            self.api_key = code;
            self.oauth_state = None;
            self.pending_auth_url = None;
            Ok(())
        } else {
            Err("No OAuth flow in progress".to_string())
        }
    }

    /// Cancel the current OAuth flow
    pub fn cancel_oauth(&mut self) {
        self.oauth_state = None;
        self.pending_auth_url = None;
        self.error_message = None;
    }

    /// Check if OAuth is available for a provider
    pub fn is_oauth_available(&self, provider: &str) -> bool {
        matches!(
            provider.to_lowercase().as_str(),
            "anthropic" | "openai" | "github" | "github-copilot"
        )
    }

    /// Get selected provider
    pub fn selected_provider(&self) -> Option<&str> {
        self.providers
            .get(self.selected_provider_index)
            .map(|s| s.as_str())
    }

    /// Input a character
    pub fn input_char(&mut self, c: char) {
        self.api_key.insert(self.cursor_pos, c);
        self.cursor_pos += 1;
        self.error_message = None;
    }

    /// Delete character before cursor
    pub fn backspace(&mut self) {
        if self.cursor_pos > 0 {
            self.cursor_pos -= 1;
            self.api_key.remove(self.cursor_pos);
            self.error_message = None;
        }
    }

    /// Cycle provider selection
    pub fn next_provider(&mut self) {
        if !self.providers.is_empty() {
            self.selected_provider_index =
                (self.selected_provider_index + 1) % self.providers.len();
            self.api_key.clear();
            self.cursor_pos = 0;
            self.error_message = None;
        }
    }

    /// Validate API key format (basic checks)
    pub fn validate(&self) -> Result<(), String> {
        if self.api_key.is_empty() {
            return Err("API key cannot be empty".to_string());
        }
        let provider = self.selected_provider().unwrap_or("");
        match provider {
            "anthropic" if !self.api_key.starts_with("sk-ant-") => {
                Err("Anthropic API keys start with 'sk-ant-'".to_string())
            }
            "openai" if !self.api_key.starts_with("sk-") => {
                Err("OpenAI API keys start with 'sk-'".to_string())
            }
            _ => Ok(()),
        }
    }

    /// Render the login dialog
    pub fn render(&self) -> String {
        let mut output = String::new();
        output.push_str(&format!("{}\n", "─".repeat(50)));
        output.push_str("  API Key Configuration\n");
        output.push_str(&format!("{}\n", "─".repeat(50)));

        // Provider tabs
        for (i, provider) in self.providers.iter().enumerate() {
            if i == self.selected_provider_index {
                output.push_str(&format!(" [{}] ", provider));
            } else {
                output.push_str(&format!("  {}  ", provider));
            }
        }
        output.push('\n');

        // API key input
        let display_key = if self.is_masked {
            "*".repeat(self.api_key.len())
        } else {
            self.api_key.clone()
        };
        output.push_str(&format!("\n  API Key: {}\n", display_key));

        // Error message
        if let Some(ref err) = self.error_message {
            output.push_str(&format!("  ⚠ {}\n", err));
        }

        output.push_str("\n  Tab: switch provider, Enter: save, Esc: cancel\n");
        output
    }
}

/// Diff line for the diff viewer
#[derive(Debug, Clone)]
pub enum DiffLine {
    Context {
        content: String,
        line_num: usize,
    },
    Added {
        content: String,
        line_num: usize,
    },
    Removed {
        content: String,
        line_num: usize,
    },
    Header {
        old_start: usize,
        old_count: usize,
        new_start: usize,
        new_count: usize,
    },
}

/// Diff viewer state
#[derive(Debug, Clone)]
pub struct DiffViewer {
    pub lines: Vec<DiffLine>,
    pub scroll_offset: usize,
    pub visible_height: usize,
    pub file_path: String,
    /// Enable word-level highlighting for changed parts
    pub word_diff: bool,
}

impl DiffViewer {
    pub fn new(file_path: String, diff_text: &str) -> Self {
        let lines = parse_diff_lines(diff_text);
        Self {
            lines,
            scroll_offset: 0,
            visible_height: 30,
            file_path,
            word_diff: true, // Enable word-level highlighting by default
        }
    }

    /// Create without word diff highlighting
    pub fn new_simple(file_path: String, diff_text: &str) -> Self {
        let lines = parse_diff_lines(diff_text);
        Self {
            lines,
            scroll_offset: 0,
            visible_height: 30,
            file_path,
            word_diff: false,
        }
    }

    /// Enable or disable word-level diff highlighting
    pub fn set_word_diff(&mut self, enabled: bool) {
        self.word_diff = enabled;
    }

    /// Render the diff viewer with optional word-level highlighting
    pub fn render(&self) -> String {
        let mut output = String::new();
        output.push_str(&format!("Diff: {}\n", self.file_path));
        output.push_str(&format!("{}\n", "─".repeat(60)));

        let visible: Vec<_> = self
            .lines
            .iter()
            .skip(self.scroll_offset)
            .take(self.visible_height)
            .collect();

        for line in &visible {
            match line {
                DiffLine::Header {
                    old_start,
                    old_count,
                    new_start,
                    new_count,
                } => {
                    output.push_str(&format!(
                        "@@ -{},{} +{},{} @@\n",
                        old_start, old_count, new_start, new_count
                    ));
                }
                DiffLine::Context { content, line_num } => {
                    output.push_str(&format!(" {:>4} {}\n", line_num, content));
                }
                DiffLine::Added { content, line_num } => {
                    if self.word_diff {
                        // Apply word-level highlighting for added lines
                        let highlighted = highlight_words_diff(content, true);
                        output.push_str(&format!("+{:>4} {}\n", line_num, highlighted));
                    } else {
                        output.push_str(&format!("+{:>4} {}\n", line_num, content));
                    }
                }
                DiffLine::Removed { content, line_num } => {
                    if self.word_diff {
                        // Apply word-level highlighting for removed lines
                        let highlighted = highlight_words_diff(content, false);
                        output.push_str(&format!("-{:>4} {}\n", line_num, highlighted));
                    } else {
                        output.push_str(&format!("-{:>4} {}\n", line_num, content));
                    }
                }
            }
        }

        let remaining = self
            .lines
            .len()
            .saturating_sub(self.scroll_offset + self.visible_height);
        if remaining > 0 {
            output.push_str(&format!("... {} more lines\n", remaining));
        }

        output
    }

    /// Scroll up
    pub fn scroll_up(&mut self, amount: usize) {
        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
    }

    /// Scroll down
    pub fn scroll_down(&mut self, amount: usize) {
        let max = self.lines.len().saturating_sub(self.visible_height);
        self.scroll_offset = (self.scroll_offset + amount).min(max);
    }
}

/// Parse unified diff text into DiffLine structs
fn parse_diff_lines(diff: &str) -> Vec<DiffLine> {
    let mut lines = Vec::new();
    let mut old_line = 0;
    let mut new_line = 0;

    for raw_line in diff.lines() {
        if raw_line.starts_with("@@") {
            // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
            if let Some(header) = parse_hunk_header(raw_line) {
                old_line = header.0;
                new_line = header.2;
                lines.push(DiffLine::Header {
                    old_start: header.0,
                    old_count: header.1,
                    new_start: header.2,
                    new_count: header.3,
                });
            }
        } else if raw_line.starts_with('+') {
            let content = raw_line[1..].to_string();
            lines.push(DiffLine::Added {
                content,
                line_num: new_line,
            });
            new_line += 1;
        } else if raw_line.starts_with('-') {
            let content = raw_line[1..].to_string();
            lines.push(DiffLine::Removed {
                content,
                line_num: old_line,
            });
            old_line += 1;
        } else if raw_line.starts_with(' ') {
            let content = raw_line[1..].to_string();
            lines.push(DiffLine::Context {
                content,
                line_num: new_line,
            });
            old_line += 1;
            new_line += 1;
        }
    }

    lines
}

fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
    // @@ -old_start,old_count +new_start,new_count @@
    let text = line.trim_start_matches('@').trim_start_matches(' ');
    let text = text.trim_end_matches('@').trim_end_matches(' ');
    let parts: Vec<&str> = text.split_whitespace().collect();
    if parts.len() < 2 {
        return None;
    }

    let old: Vec<usize> = parts[0]
        .trim_start_matches('-')
        .split(',')
        .filter_map(|s| s.parse().ok())
        .collect();
    let new: Vec<usize> = parts
        .get(1)?
        .trim_start_matches('+')
        .split(',')
        .filter_map(|s| s.parse().ok())
        .collect();

    Some((
        *old.first()?,
        *old.get(1).unwrap_or(&1),
        *new.first()?,
        *new.get(1).unwrap_or(&1),
    ))
}

/// Highlight word-level changes in a diff line
/// Returns the content with ANSI color codes for changed words.
fn highlight_words_diff(content: &str, is_added: bool) -> String {
    use std::fmt::Write;

    // Split content into words while preserving spaces
    let words: Vec<&str> = content.split_whitespace().collect();
    let mut result = String::new();

    for (i, word) in words.iter().enumerate() {
        // Simple heuristic: short words (1-4 chars) that differ are likely changed
        let is_short_change = word.len() <= 4 && !word.chars().all(|c| c.is_alphanumeric());

        if is_short_change && i > 0 {
            // Highlight as changed
            let color = if is_added { "\x1b[32m" } else { "\x1b[31m" };
            write!(&mut result, "{}{}{}\x1b[0m ", color, word, "\x1b[0m").unwrap();
        } else {
            write!(&mut result, "{} ", word).unwrap();
        }
    }

    result.trim_end().to_string()
}

/// Tool result content block
#[derive(Debug, Clone)]
pub enum ToolContentBlock {
    /// Text output
    Text { text: String },
    /// Image data (base64 encoded)
    Image { data: String, mime_type: String },
}

/// Tool execution result
#[derive(Debug, Clone)]
pub struct ToolResult {
    pub content: Vec<ToolContentBlock>,
    pub is_error: bool,
    pub details: Option<serde_json::Value>,
}

impl ToolResult {
    pub fn new_text(text: impl Into<String>) -> Self {
        Self {
            content: vec![ToolContentBlock::Text { text: text.into() }],
            is_error: false,
            details: None,
        }
    }

    pub fn error(text: impl Into<String>) -> Self {
        Self {
            content: vec![ToolContentBlock::Text { text: text.into() }],
            is_error: true,
            details: None,
        }
    }

    /// Get text output (first text block or concatenated)
    pub fn get_text(&self) -> Option<String> {
        let texts: Vec<_> = self
            .content
            .iter()
            .filter_map(|b| match b {
                ToolContentBlock::Text { text } => Some(text.clone()),
                ToolContentBlock::Image { .. } => None,
            })
            .collect();

        if texts.is_empty() {
            None
        } else {
            Some(texts.join("\n"))
        }
    }

    /// Check if result contains images
    pub fn has_images(&self) -> bool {
        self.content
            .iter()
            .any(|b| matches!(b, ToolContentBlock::Image { .. }))
    }

    /// Count images in result
    pub fn image_count(&self) -> usize {
        self.content
            .iter()
            .filter(|b| matches!(b, ToolContentBlock::Image { .. }))
            .count()
    }
}

/// Tool execution state
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolExecutionState {
    /// Tool call has been made, awaiting execution
    Pending,
    /// Tool is currently executing
    Running,
    /// Tool completed successfully
    Success,
    /// Tool completed with error
    Error,
}

/// Tool execution display state
#[derive(Debug, Clone)]
pub struct ToolExecution {
    pub tool_name: String,
    pub tool_call_id: String,
    pub arguments: serde_json::Value,
    pub state: ToolExecutionState,
    pub result: Option<ToolResult>,
    pub expanded: bool,
    pub show_images: bool,
}

impl ToolExecution {
    pub fn new(
        tool_name: impl Into<String>,
        tool_call_id: impl Into<String>,
        args: serde_json::Value,
    ) -> Self {
        Self {
            tool_name: tool_name.into(),
            tool_call_id: tool_call_id.into(),
            arguments: args,
            state: ToolExecutionState::Pending,
            result: None,
            expanded: false,
            show_images: true,
        }
    }

    /// Mark execution as started
    pub fn start(&mut self) {
        self.state = ToolExecutionState::Running;
    }

    /// Complete with result
    pub fn complete(&mut self, result: ToolResult) {
        self.state = if result.is_error {
            ToolExecutionState::Error
        } else {
            ToolExecutionState::Success
        };
        self.result = Some(result);
    }

    /// Set expanded/collapsed state
    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Toggle expanded state
    pub fn toggle_expanded(&mut self) {
        self.expanded = !self.expanded;
    }

    /// Format arguments for display
    pub fn format_arguments(&self) -> String {
        if self.arguments.is_null() {
            String::new()
        } else if let Ok(obj) = serde_json::from_value::<serde_json::Value>(self.arguments.clone())
        {
            serde_json::to_string_pretty(&obj).unwrap_or_else(|_| self.arguments.to_string())
        } else {
            self.arguments.to_string()
        }
    }

    /// Render the tool execution
    pub fn render(&self) -> String {
        let mut output = String::new();

        // Determine colors based on state
        let (bg_color, fg_color, status_icon) = match self.state {
            ToolExecutionState::Pending => ("\x1b[48;5;240m", "\x1b[38;5;250m", "○"),
            ToolExecutionState::Running => ("\x1b[48;5;239m", "\x1b[38;5;250m", "◐"),
            ToolExecutionState::Success => ("\x1b[48;5;28m", "\x1b[38;5;255m", "●"),
            ToolExecutionState::Error => ("\x1b[48;5;196m", "\x1b[38;5;255m", "✗"),
        };
        let reset = "\x1b[0m";

        // Tool header
        output.push_str(&format!(
            "{} {} {}\x1b[1m{}\x1b[0m{}",
            bg_color, status_icon, fg_color, self.tool_name, reset
        ));
        output.push('\n');

        // Arguments (if expanded or small)
        let args_str = self.format_arguments();
        if self.expanded || args_str.len() < 200 {
            if !args_str.is_empty() {
                // Pretty print arguments
                output.push_str(&format!("{}Arguments:{} {}\n", fg_color, reset, args_str));
            }
        } else {
            // Show truncated args
            let truncated = if args_str.len() > 100 {
                format!("{}...\x1b[0m ({} chars)", &args_str[..100], args_str.len())
            } else {
                args_str
            };
            output.push_str(&format!("{}Arguments:{} {}\n", fg_color, reset, truncated));
        }

        // Result
        if let Some(ref result) = self.result {
            let result_fg = if result.is_error {
                "\x1b[31m"
            } else {
                fg_color
            };

            if self.expanded {
                // Show full result
                if let Some(text) = result.get_text() {
                    output.push_str(&format!("{}Output:{}\n{}", result_fg, reset, text));
                    if !text.ends_with('\n') {
                        output.push('\n');
                    }
                }

                // Show images count
                if result.has_images() && self.show_images {
                    output.push_str(&format!(
                        "{} ({} image{})",
                        fg_color,
                        result.image_count(),
                        if result.image_count() == 1 { "" } else { "s" }
                    ));
                }
            } else {
                // Show truncated result
                if let Some(text) = result.get_text() {
                    let truncated = truncate_text(&text, 500);
                    output.push_str(&format!("{}Output:{} {}", result_fg, reset, truncated));
                    if text.len() > 500 {
                        output.push_str(" (use → to expand)");
                    }
                    output.push('\n');
                } else if result.has_images() {
                    output.push_str(&format!(
                        "{}Output:{} {} image{}\n",
                        result_fg,
                        reset,
                        result.image_count(),
                        if result.image_count() == 1 { "" } else { "s" }
                    ));
                }
            }
        } else if self.state == ToolExecutionState::Running {
            output.push_str(&format!("{}Running...{}", fg_color, reset));
            output.push('\n');
        }

        output
    }
}

/// Truncate text to max length with ellipsis
fn truncate_text(text: &str, max_len: usize) -> String {
    if text.len() <= max_len {
        return text.to_string();
    }

    // Find a good break point
    let truncated = &text[..max_len];
    if let Some(last_newline) = truncated.rfind('\n') {
        format!("{}...", &truncated[..last_newline])
    } else if let Some(last_space) = truncated.rfind(' ') {
        format!("{}...", &truncated[..last_space])
    } else {
        format!("{}...", truncated)
    }
}

/// Tool execution renderer with advanced options
pub struct ToolExecutionRenderer {
    pub show_images: bool,
    pub max_image_width: usize,
}

impl ToolExecutionRenderer {
    pub fn new() -> Self {
        Self {
            show_images: true,
            max_image_width: 80,
        }
    }

    pub fn with_show_images(mut self, show: bool) -> Self {
        self.show_images = show;
        self
    }

    pub fn with_max_image_width(mut self, width: usize) -> Self {
        self.max_image_width = width;
        self
    }

    /// Render multiple tool executions
    pub fn render_all(&self, executions: &[ToolExecution]) -> String {
        executions
            .iter()
            .map(|e| e.render())
            .collect::<Vec<_>>()
            .join("\n")
    }
}

impl Default for ToolExecutionRenderer {
    fn default() -> Self {
        Self::new()
    }
}

/// Format bytes for human-readable display
fn format_bytes(bytes: usize) -> String {
    if bytes < 1024 {
        format!("{}B", bytes)
    } else if bytes < 1024 * 1024 {
        format!("{:.1}KB", bytes as f64 / 1024.0)
    } else if bytes < 1024 * 1024 * 1024 {
        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
    } else {
        format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
    }
}

/// Enhanced Bash execution state with output truncation and streaming support
#[derive(Debug, Clone)]
pub struct BashExecution {
    pub command: String,
    pub output: String,
    pub exit_code: Option<i32>,
    pub start_time: std::time::Instant,
    pub is_running: bool,
    pub is_cancelled: bool,
    /// Whether the output is expanded (shows full output) or collapsed (preview only)
    pub expanded: bool,
    /// Truncation result for context limits
    pub truncation_info: Option<TruncationInfo>,
    /// Path to full output file if truncated
    pub full_output_path: Option<String>,
}

/// Information about output truncation
#[derive(Debug, Clone)]
pub struct TruncationInfo {
    /// Total lines before truncation
    pub total_lines: usize,
    /// Lines shown after truncation
    pub shown_lines: usize,
    /// Bytes before truncation
    pub total_bytes: usize,
}

impl BashExecution {
    pub fn new(command: String) -> Self {
        Self {
            command,
            output: String::new(),
            exit_code: None,
            start_time: std::time::Instant::now(),
            is_running: true,
            is_cancelled: false,
            expanded: false,
            truncation_info: None,
            full_output_path: None,
        }
    }

    /// Append output (stripping ANSI codes for display)
    pub fn append_output(&mut self, chunk: &str) {
        // Strip ANSI codes and normalize line endings
        let clean = strip_ansi(chunk).replace("\r\n", "\n").replace("\r", "\n");

        // Append to output
        if !self.output.is_empty() && !clean.is_empty() {
            self.output.push_str(&clean);
        } else {
            self.output.push_str(&clean);
        }
    }

    /// Mark as complete
    pub fn complete(&mut self, exit_code: i32) {
        self.exit_code = Some(exit_code);
        self.is_running = false;
    }

    /// Complete with truncation info
    pub fn complete_with_truncation(
        &mut self,
        exit_code: i32,
        truncation_info: TruncationInfo,
        full_output_path: Option<String>,
    ) {
        self.exit_code = Some(exit_code);
        self.is_running = false;
        self.truncation_info = Some(truncation_info);
        self.full_output_path = full_output_path;
    }

    /// Cancel execution
    pub fn cancel(&mut self) {
        self.is_cancelled = true;
        self.is_running = false;
        self.exit_code = Some(-1);
        self.output.push_str("\n[Cancelled]");
    }

    /// Get elapsed time
    pub fn elapsed(&self) -> std::time::Duration {
        self.start_time.elapsed()
    }

    /// Set expanded/collapsed state
    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Toggle expanded state
    pub fn toggle_expanded(&mut self) {
        self.expanded = !self.expanded;
    }

    /// Get output lines
    pub fn output_lines(&self) -> Vec<&str> {
        self.output.lines().collect()
    }

    /// Get raw output string
    pub fn get_output(&self) -> &str {
        &self.output
    }

    /// Render the bash execution display
    pub fn render(&self) -> String {
        let mut output = String::new();

        // Preview line limit (when not expanded)
        const PREVIEW_LINES: usize = 20;

        // Command header with styling
        output.push_str(&format!("\x1b[1m$ {}\x1b[0m\n", self.command));

        // Process output lines for display
        let lines: Vec<&str> = self.output.lines().collect();
        let total_lines = lines.len();
        let hidden_lines = if total_lines > PREVIEW_LINES && !self.expanded {
            total_lines - PREVIEW_LINES
        } else {
            0
        };

        // Show output
        if !self.output.is_empty() {
            let lines_to_show = if self.expanded {
                &lines[..]
            } else {
                // Show last PREVIEW_LINES
                if lines.len() > PREVIEW_LINES {
                    &lines[lines.len() - PREVIEW_LINES..]
                } else {
                    &lines[..]
                }
            };

            // Muted color for output
            for line in lines_to_show {
                output.push_str(&format!("\x1b[2m{}\x1b[0m\n", line));
            }
        }

        // Status line
        if self.is_running {
            // Running state with elapsed time
            output.push_str(&format!(
                "\x1b[90mRunning... ({:.1}s)\x1b[0m\n",
                self.elapsed().as_secs_f64()
            ));
        } else {
            let mut status_parts = Vec::new();

            // Hidden lines indicator
            if hidden_lines > 0 {
                if self.expanded {
                    status_parts.push("\x1b[2m(to collapse)\x1b[0m".to_string());
                } else {
                    status_parts.push(format!("\x1b[2m... {} more lines\x1b[0m", hidden_lines));
                }
            }

            // Status
            if self.is_cancelled {
                status_parts.push("\x1b[33m(cancelled)\x1b[0m".to_string());
            } else if let Some(code) = self.exit_code {
                if code != 0 {
                    status_parts.push(format!("\x1b[31m(exit {})\x1b[0m", code));
                }
            }

            // Truncation warning
            if self.truncation_info.is_some() {
                if let Some(ref path) = self.full_output_path {
                    status_parts.push(format!(
                        "\x1b[33mOutput truncated. Full output: {}\x1b[0m",
                        path
                    ));
                }
            }

            if !status_parts.is_empty() {
                output.push_str(&status_parts.join(" "));
                output.push('\n');
            }
        }

        output
    }
}

/// Strip ANSI escape codes from a string
fn strip_ansi(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\x1b' {
            // CSI sequence
            if chars.next() == Some('[') {
                // Read until we hit a letter
                while let Some(&ch) = chars.peek() {
                    if ch.is_ascii_alphabetic() {
                        chars.next();
                        break;
                    }
                    chars.next();
                }
            }
        } else {
            result.push(c);
        }
    }

    result
}

/// Summary message types for compaction and branch summaries
#[derive(Debug, Clone)]
pub enum SummaryMessageType {
    /// Compaction summary after context window management
    Compaction { tokens_before: usize },
    /// Branch summary when creating/merging branches
    Branch,
}

/// Summary message data
#[derive(Debug, Clone)]
pub struct SummaryMessage {
    pub message_type: SummaryMessageType,
    pub summary: String,
    pub expanded: bool,
}

impl SummaryMessage {
    pub fn compaction(tokens_before: usize, summary: impl Into<String>) -> Self {
        Self {
            message_type: SummaryMessageType::Compaction { tokens_before },
            summary: summary.into(),
            expanded: false,
        }
    }

    pub fn branch(summary: impl Into<String>) -> Self {
        Self {
            message_type: SummaryMessageType::Branch,
            summary: summary.into(),
            expanded: false,
        }
    }

    /// Set expanded/collapsed state
    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Toggle expanded state
    pub fn toggle_expanded(&mut self) {
        self.expanded = !self.expanded;
    }

    /// Render the summary message
    pub fn render(&self) -> String {
        let mut output = String::new();

        // Label with bold styling
        let label = match &self.message_type {
            SummaryMessageType::Compaction { tokens_before } => {
                format!(
                    "\x1b[1m[compaction]\x1b[0m Compacted from {} tokens",
                    tokens_before
                )
            }
            SummaryMessageType::Branch => "\x1b[1m[branch]\x1b[0m Branch Summary".to_string(),
        };

        output.push_str(&format!("\x1b[48;5;24m {} \x1b[0m\n", label));

        if self.expanded {
            // Show full summary with markdown-style formatting
            output.push_str("\n");
            output.push_str(&render_markdown(&self.summary));
            output.push('\n');
        } else {
            // Show collapsed hint
            output.push_str(&format!("\x1b[2m(to expand)\x1b[0m\n",));
        }

        output
    }
}

/// Summary message renderer with options
pub struct SummaryMessageRenderer;

impl SummaryMessageRenderer {
    /// Render a compaction summary
    pub fn render_compaction(tokens_before: usize, summary: &str, expanded: bool) -> String {
        let mut msg = SummaryMessage::compaction(tokens_before, summary);
        msg.set_expanded(expanded);
        msg.render()
    }

    /// Render a branch summary
    pub fn render_branch(summary: &str, expanded: bool) -> String {
        let mut msg = SummaryMessage::branch(summary);
        msg.set_expanded(expanded);
        msg.render()
    }
}

// ═══════════════════════════════════════════════════════════════════════════
// Extension-Related Interactive Components
// ═══════════════════════════════════════════════════════════════════════════

/// Editor for extension-provided input.
///
/// Displays a bordered, titled multi-line text area with hint text
/// showing keybindings for submit / newline / cancel / external editor.
#[derive(Debug, Clone)]
pub struct ExtensionEditor {
    /// Title displayed above the editor.
    pub title: String,
    /// Current text content.
    pub text: String,
    /// Cursor position within `text`.
    pub cursor_pos: usize,
    /// Whether an external editor is available ($VISUAL or $EDITOR).
    pub has_external_editor: bool,
}

impl ExtensionEditor {
    /// Create a new extension editor with a title and optional prefill text.
    pub fn new(title: impl Into<String>, prefill: Option<&str>) -> Self {
        let text = prefill.unwrap_or("").to_string();
        let cursor_pos = text.len();
        let has_external_editor = std::env::var("VISUAL")
            .or_else(|_| std::env::var("EDITOR"))
            .is_ok();
        Self {
            title: title.into(),
            text,
            cursor_pos,
            has_external_editor,
        }
    }

    /// Insert a character at the cursor position.
    pub fn input_char(&mut self, c: char) {
        self.text.insert(self.cursor_pos, c);
        self.cursor_pos += c.len_utf8();
    }

    /// Delete character before cursor.
    pub fn backspace(&mut self) {
        if self.cursor_pos > 0 {
            // Walk back to the previous char boundary
            let prev = self.text[..self.cursor_pos]
                .char_indices()
                .last()
                .map(|(i, _)| i)
                .unwrap_or(0);
            self.text.drain(prev..self.cursor_pos);
            self.cursor_pos = prev;
        }
    }

    /// Replace the full text (e.g. after external editor session).
    pub fn set_text(&mut self, text: impl Into<String>) {
        self.text = text.into();
        self.cursor_pos = self.text.len();
    }

    /// Render the editor component as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let border = "─".repeat(60);
        let mut lines = Vec::new();

        lines.push(border.clone());
        lines.push(String::new()); // spacer
        lines.push(format!("\x1b[36m{}\x1b[0m", self.title));
        lines.push(String::new()); // spacer

        // Editor content (each source line as a rendered line)
        for line in self.text.lines() {
            lines.push(line.to_string());
        }
        if self.text.is_empty() {
            lines.push(String::new());
        }

        lines.push(String::new()); // spacer

        // Hint line
        let mut hints = vec!["Enter: submit", "Shift+Enter: newline", "Esc: cancel"];
        if self.has_external_editor {
            hints.push("Ctrl+G: external editor");
        }
        lines.push(format!("\x1b[2m{}\x1b[0m", hints.join("  ")));
        lines.push(String::new()); // spacer
        lines.push(border);

        lines
    }
}

/// Simple text input component for extensions.
///
/// Displays a bordered, titled single-line input with optional timeout
/// countdown shown in the title.
#[derive(Debug, Clone)]
pub struct ExtensionInput {
    /// Title displayed above the input.
    pub title: String,
    /// Current input value.
    pub value: String,
    /// Cursor position within `value`.
    pub cursor_pos: usize,
    /// Optional timeout in seconds (shown as countdown in title).
    pub timeout_secs: Option<u64>,
    /// Remaining seconds (when timeout is active).
    pub remaining_secs: Option<u64>,
}

impl ExtensionInput {
    /// Create a new extension input with a title and optional timeout.
    pub fn new(title: impl Into<String>, timeout_secs: Option<u64>) -> Self {
        Self {
            title: title.into(),
            value: String::new(),
            cursor_pos: 0,
            timeout_secs,
            remaining_secs: timeout_secs,
        }
    }

    /// Insert a character at the cursor position.
    pub fn input_char(&mut self, c: char) {
        self.value.insert(self.cursor_pos, c);
        self.cursor_pos += c.len_utf8();
    }

    /// Delete character before cursor.
    pub fn backspace(&mut self) {
        if self.cursor_pos > 0 {
            let prev = self.value[..self.cursor_pos]
                .char_indices()
                .last()
                .map(|(i, _)| i)
                .unwrap_or(0);
            self.value.drain(prev..self.cursor_pos);
            self.cursor_pos = prev;
        }
    }

    /// Update the countdown timer.
    pub fn tick(&mut self) -> bool {
        if let Some(ref mut remaining) = self.remaining_secs {
            if *remaining > 0 {
                *remaining -= 1;
                return *remaining > 0;
            }
            return false;
        }
        true
    }

    /// Render the input component as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let border = "─".repeat(60);
        let mut lines = Vec::new();

        lines.push(border.clone());
        lines.push(String::new()); // spacer

        // Title with optional countdown
        let title_display = if let Some(remaining) = self.remaining_secs {
            format!("{} ({}s)", self.title, remaining)
        } else {
            self.title.clone()
        };
        lines.push(format!("\x1b[36m{}\x1b[0m", title_display));
        lines.push(String::new()); // spacer

        // Input field
        lines.push(format!("> {}", self.value));
        lines.push(String::new()); // spacer

        // Hints
        lines.push(format!("\x1b[2mEnter: submit  Esc: cancel\x1b[0m"));
        lines.push(String::new()); // spacer
        lines.push(border);

        lines
    }
}

/// Generic selector component for extensions.
///
/// Displays a list of string options with keyboard navigation
/// (up/down arrows, j/k) and selection (Enter).
#[derive(Debug, Clone)]
pub struct ExtensionSelector {
    /// Title displayed above the list.
    pub title: String,
    /// Available options.
    pub options: Vec<String>,
    /// Currently selected index.
    pub selected_index: usize,
    /// Optional timeout in seconds.
    pub timeout_secs: Option<u64>,
    /// Remaining seconds (when timeout is active).
    pub remaining_secs: Option<u64>,
}

impl ExtensionSelector {
    /// Create a new extension selector with a title and list of options.
    pub fn new(title: impl Into<String>, options: Vec<String>, timeout_secs: Option<u64>) -> Self {
        Self {
            title: title.into(),
            selected_index: 0,
            timeout_secs,
            remaining_secs: timeout_secs,
            options,
        }
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        if self.selected_index < self.options.len().saturating_sub(1) {
            self.selected_index += 1;
        }
    }

    /// Get the currently selected option.
    pub fn selected(&self) -> Option<&str> {
        self.options.get(self.selected_index).map(|s| s.as_str())
    }

    /// Update the countdown timer.
    pub fn tick(&mut self) -> bool {
        if let Some(ref mut remaining) = self.remaining_secs {
            if *remaining > 0 {
                *remaining -= 1;
                return *remaining > 0;
            }
            return false;
        }
        true
    }

    /// Render the selector component as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let border = "─".repeat(60);
        let mut lines = Vec::new();

        lines.push(border.clone());
        lines.push(String::new()); // spacer

        // Title with optional countdown
        let title_display = if let Some(remaining) = self.remaining_secs {
            format!("{} ({}s)", self.title, remaining)
        } else {
            self.title.clone()
        };
        lines.push(format!("\x1b[1m\x1b[36m{}\x1b[0m", title_display));
        lines.push(String::new()); // spacer

        // Options
        for (i, option) in self.options.iter().enumerate() {
            if i == self.selected_index {
                lines.push(format!("\x1b[36m→ {}\x1b[0m", option));
            } else {
                lines.push(format!("  \x1b[37m{}\x1b[0m", option));
            }
        }

        lines.push(String::new()); // spacer

        // Hints
        lines.push(format!(
            "\x1b[2m↑↓: navigate  Enter: select  Esc: cancel\x1b[0m"
        ));
        lines.push(String::new()); // spacer
        lines.push(border);

        lines
    }
}

/// Custom editor registered by extensions.
///
/// Wraps an editor-like buffer with extension action handlers
/// for escape, Ctrl+D, paste-image, and extension-registered shortcuts.
#[derive(Debug, Clone)]
pub struct CustomEditor {
    /// Current text content.
    pub text: String,
    /// Cursor position within `text`.
    pub cursor_pos: usize,
    /// Whether autocomplete is currently showing.
    pub showing_autocomplete: bool,
    /// Registered action handlers (action name → description).
    pub registered_actions: Vec<String>,
}

impl CustomEditor {
    /// Create a new custom editor.
    pub fn new() -> Self {
        Self {
            text: String::new(),
            cursor_pos: 0,
            showing_autocomplete: false,
            registered_actions: Vec::new(),
        }
    }

    /// Create with initial text content.
    pub fn with_text(text: impl Into<String>) -> Self {
        let text = text.into();
        let cursor_pos = text.len();
        Self {
            text,
            cursor_pos,
            showing_autocomplete: false,
            registered_actions: Vec::new(),
        }
    }

    /// Insert a character at the cursor position.
    pub fn input_char(&mut self, c: char) {
        self.text.insert(self.cursor_pos, c);
        self.cursor_pos += c.len_utf8();
        self.showing_autocomplete = false;
    }

    /// Delete character before cursor.
    pub fn backspace(&mut self) {
        if self.cursor_pos > 0 {
            let prev = self.text[..self.cursor_pos]
                .char_indices()
                .last()
                .map(|(i, _)| i)
                .unwrap_or(0);
            self.text.drain(prev..self.cursor_pos);
            self.cursor_pos = prev;
        }
        self.showing_autocomplete = false;
    }

    /// Check if the editor is empty (for Ctrl+D exit detection).
    pub fn is_empty(&self) -> bool {
        self.text.is_empty()
    }

    /// Register an action handler.
    pub fn register_action(&mut self, action: impl Into<String>) {
        let action = action.into();
        if !self.registered_actions.contains(&action) {
            self.registered_actions.push(action);
        }
    }

    /// Render the custom editor as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let mut lines = Vec::new();

        // Show text content line-by-line
        for line in self.text.lines() {
            lines.push(line.to_string());
        }
        if self.text.is_empty() {
            lines.push(String::new());
        }

        lines
    }
}

impl Default for CustomEditor {
    fn default() -> Self {
        Self::new()
    }
}

/// Custom message type rendering.
///
/// Renders extension-provided custom messages with a styled label
/// and markdown content, similar to how pi-mono renders custom
/// message entries.
#[derive(Debug, Clone)]
pub struct CustomMessageComponent {
    /// The custom type label.
    pub custom_type: String,
    /// The message content (plain text).
    pub content: String,
    /// Whether the message is expanded.
    pub expanded: bool,
}

impl CustomMessageComponent {
    /// Create a new custom message component.
    pub fn new(custom_type: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            custom_type: custom_type.into(),
            content: content.into(),
            expanded: false,
        }
    }

    /// Set expanded/collapsed state.
    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Toggle expanded state.
    pub fn toggle_expanded(&mut self) {
        self.expanded = !self.expanded;
    }

    /// Render the custom message as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let mut lines = Vec::new();

        lines.push(String::new()); // spacer

        // Label with type
        let label = format!("\x1b[35m\x1b[1m[{}]\x1b[0m", self.custom_type);
        lines.push(label);
        lines.push(String::new()); // spacer

        // Content with background
        if self.expanded {
            for line in self.content.lines() {
                lines.push(format!("\x1b[48;5;54m {} \x1b[0m", line));
            }
        } else {
            // Show first line as preview
            let first_line = self.content.lines().next().unwrap_or("");
            let preview = if first_line.len() > 80 {
                format!("{}...", &first_line[..80])
            } else if first_line.is_empty() {
                "(empty)".to_string()
            } else {
                first_line.to_string()
            };
            lines.push(format!("\x1b[48;5;54m {} \x1b[0m", preview));
            let total_lines = self.content.lines().count();
            if total_lines > 1 {
                lines.push(format!(
                    "\x1b[2m  ... {} more lines\x1b[0m",
                    total_lines.saturating_sub(1)
                ));
            }
        }

        lines
    }
}

/// Login dialog state for provider login flow.
///
/// Supports multiple login phases: URL display, manual code input,
/// prompt display, waiting, and progress messages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoginDialogPhase {
    /// Initial state.
    Init,
    /// Showing authorization URL to the user.
    ShowAuth {
        url: String,
        instructions: Option<String>,
    },
    /// Waiting for user to manually input a code/URL.
    ManualInput {
        prompt: String,
        value: String,
        cursor_pos: usize,
    },
    /// Showing a prompt and waiting for input.
    Prompt {
        message: String,
        placeholder: Option<String>,
        value: String,
        cursor_pos: usize,
    },
    /// Showing informational text.
    Info { lines: Vec<String> },
    /// Showing a waiting/polling message.
    Waiting { message: String },
    /// Login completed (success or failure).
    Completed {
        success: bool,
        message: Option<String>,
    },
}

/// Provider login dialog component.
///
/// Manages the full lifecycle of an OAuth or API-key login flow,
/// rendering appropriate UI at each phase.
#[derive(Debug, Clone)]
pub struct ProviderLoginDialog {
    /// Provider ID (e.g. "anthropic", "openai").
    pub provider_id: String,
    /// Provider display name.
    pub provider_name: String,
    /// Optional custom title.
    pub title: Option<String>,
    /// Current phase of the login flow.
    pub phase: LoginDialogPhase,
}

impl ProviderLoginDialog {
    /// Create a new provider login dialog.
    pub fn new(provider_id: impl Into<String>, provider_name: impl Into<String>) -> Self {
        Self {
            provider_id: provider_id.into(),
            provider_name: provider_name.into(),
            title: None,
            phase: LoginDialogPhase::Init,
        }
    }

    /// Set a custom title.
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Transition to the show-auth phase.
    pub fn show_auth(&mut self, url: impl Into<String>, instructions: Option<String>) {
        self.phase = LoginDialogPhase::ShowAuth {
            url: url.into(),
            instructions,
        };
    }

    /// Transition to the manual-input phase.
    pub fn show_manual_input(&mut self, prompt: impl Into<String>) {
        self.phase = LoginDialogPhase::ManualInput {
            prompt: prompt.into(),
            value: String::new(),
            cursor_pos: 0,
        };
    }

    /// Transition to the prompt phase.
    pub fn show_prompt(&mut self, message: impl Into<String>, placeholder: Option<String>) {
        self.phase = LoginDialogPhase::Prompt {
            message: message.into(),
            placeholder,
            value: String::new(),
            cursor_pos: 0,
        };
    }

    /// Transition to the info phase.
    pub fn show_info(&mut self, lines: Vec<String>) {
        self.phase = LoginDialogPhase::Info { lines };
    }

    /// Transition to the waiting phase.
    pub fn show_waiting(&mut self, message: impl Into<String>) {
        self.phase = LoginDialogPhase::Waiting {
            message: message.into(),
        };
    }

    /// Show a progress message (appends to current phase).
    pub fn show_progress(&mut self, message: &str) {
        // Progress messages are displayed during waiting/polling
        match &mut self.phase {
            LoginDialogPhase::Waiting {
                message: ref mut msg,
            } => {
                msg.push_str(&format!("\n{}", message));
            }
            _ => {
                self.phase = LoginDialogPhase::Waiting {
                    message: message.to_string(),
                };
            }
        }
    }

    /// Complete the login flow.
    pub fn complete(&mut self, success: bool, message: Option<String>) {
        self.phase = LoginDialogPhase::Completed { success, message };
    }

    /// Input a character (for ManualInput and Prompt phases).
    pub fn input_char(&mut self, c: char) {
        match &mut self.phase {
            LoginDialogPhase::ManualInput {
                value, cursor_pos, ..
            }
            | LoginDialogPhase::Prompt {
                value, cursor_pos, ..
            } => {
                value.insert(*cursor_pos, c);
                *cursor_pos += c.len_utf8();
            }
            _ => {}
        }
    }

    /// Backspace (for ManualInput and Prompt phases).
    pub fn backspace(&mut self) {
        match &mut self.phase {
            LoginDialogPhase::ManualInput {
                value, cursor_pos, ..
            }
            | LoginDialogPhase::Prompt {
                value, cursor_pos, ..
            } => {
                if *cursor_pos > 0 {
                    let prev = value[..*cursor_pos]
                        .char_indices()
                        .last()
                        .map(|(i, _)| i)
                        .unwrap_or(0);
                    value.drain(prev..*cursor_pos);
                    *cursor_pos = prev;
                }
            }
            _ => {}
        }
    }

    /// Get the current input value (for ManualInput and Prompt phases).
    pub fn input_value(&self) -> Option<&str> {
        match &self.phase {
            LoginDialogPhase::ManualInput { value, .. }
            | LoginDialogPhase::Prompt { value, .. } => Some(value.as_str()),
            _ => None,
        }
    }

    /// Render the login dialog as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let border = "─".repeat(60);
        let mut lines = Vec::new();

        // Title
        let title = self
            .title
            .clone()
            .unwrap_or_else(|| format!("Login to {}", self.provider_name));
        lines.push(border.clone());
        lines.push(format!("\x1b[1m\x1b[36m{}\x1b[0m", title));

        // Content based on phase
        match &self.phase {
            LoginDialogPhase::Init => {
                lines.push(String::new());
                lines.push("Initializing...".to_string());
            }
            LoginDialogPhase::ShowAuth { url, instructions } => {
                lines.push(String::new());
                lines.push(format!("\x1b[36m{}\x1b[0m", url));
                lines.push(format!("\x1b[2mCmd+click to open\x1b[0m"));
                if let Some(instr) = instructions {
                    lines.push(String::new());
                    lines.push(format!("\x1b[33m{}\x1b[0m", instr));
                }
            }
            LoginDialogPhase::ManualInput {
                prompt,
                value,
                cursor_pos: _,
            } => {
                lines.push(String::new());
                lines.push(format!("\x1b[2m{}\x1b[0m", prompt));
                lines.push(format!("> {}", value));
                lines.push(format!("\x1b[2m(Esc to cancel)\x1b[0m"));
            }
            LoginDialogPhase::Prompt {
                message,
                placeholder,
                value,
                cursor_pos: _,
            } => {
                lines.push(String::new());
                lines.push(message.clone());
                if let Some(ph) = placeholder {
                    lines.push(format!("\x1b[2me.g., {}\x1b[0m", ph));
                }
                lines.push(format!("> {}", value));
                lines.push(format!("\x1b[2m(Esc to cancel, Enter to submit)\x1b[0m"));
            }
            LoginDialogPhase::Info { lines: info_lines } => {
                lines.push(String::new());
                for line in info_lines {
                    lines.push(line.clone());
                }
                lines.push(String::new());
                lines.push(format!("\x1b[2m(Esc to close)\x1b[0m"));
            }
            LoginDialogPhase::Waiting { message } => {
                lines.push(String::new());
                lines.push(format!("\x1b[2m{}\x1b[0m", message));
                lines.push(format!("\x1b[2m(Esc to cancel)\x1b[0m"));
            }
            LoginDialogPhase::Completed { success, message } => {
                lines.push(String::new());
                if *success {
                    lines.push(format!("\x1b[32m✓ Login successful\x1b[0m"));
                } else {
                    lines.push(format!("\x1b[31m✗ Login failed\x1b[0m"));
                }
                if let Some(msg) = message {
                    lines.push(msg.clone());
                }
            }
        }

        lines.push(border);
        lines
    }
}

/// Auth provider information for the OAuth selector.
#[derive(Debug, Clone)]
pub struct AuthProviderInfo {
    /// Unique provider ID.
    pub id: String,
    /// Display name.
    pub name: String,
    /// Authentication type.
    pub auth_type: AuthType,
}

/// Authentication type for a provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthType {
    /// OAuth flow.
    OAuth,
    /// Simple API key.
    ApiKey,
}

/// Configuration status for a provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderConfigStatus {
    /// Not configured at all.
    Unconfigured,
    /// Configured with the matching auth type.
    Configured { label: String },
    /// Configured with a different auth type.
    PartiallyConfigured { label: String },
}

/// OAuth selector component.
///
/// Displays a searchable, scrollable list of auth providers with
/// status indicators and navigation. Supports both login and logout modes.
#[derive(Debug, Clone)]
pub struct OAuthSelector {
    /// Mode: login or logout.
    pub mode: OAuthSelectorMode,
    /// All available providers.
    pub providers: Vec<AuthProviderInfo>,
    /// Currently filtered providers (indices into `providers`).
    pub filtered_indices: Vec<usize>,
    /// Currently selected index (into `filtered_indices`).
    pub selected_index: usize,
    /// Search filter text.
    pub filter: String,
    /// Scroll offset for the visible window.
    pub scroll_offset: usize,
    /// Max visible items at once.
    pub visible_height: usize,
    /// Config status for each provider (indexed by provider ID).
    pub config_status: std::collections::HashMap<String, ProviderConfigStatus>,
}

/// Mode for the OAuth selector.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthSelectorMode {
    /// Selecting a provider to log into.
    Login,
    /// Selecting a provider to log out of.
    Logout,
}

impl OAuthSelector {
    /// Create a new OAuth selector.
    pub fn new(mode: OAuthSelectorMode, providers: Vec<AuthProviderInfo>) -> Self {
        let filtered_indices: Vec<usize> = (0..providers.len()).collect();
        Self {
            mode,
            providers,
            filtered_indices,
            selected_index: 0,
            filter: String::new(),
            scroll_offset: 0,
            visible_height: 8,
            config_status: std::collections::HashMap::new(),
        }
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.filtered_indices.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
            self.adjust_scroll();
        }
    }

    /// Get the currently selected provider.
    pub fn selected(&self) -> Option<&AuthProviderInfo> {
        self.filtered_indices
            .get(self.selected_index)
            .and_then(|&idx| self.providers.get(idx))
    }

    /// Update the search filter and recompute filtered list.
    pub fn set_filter(&mut self, filter: String) {
        self.filter = filter;
        let filter_lower = self.filter.to_lowercase();
        self.filtered_indices = self
            .providers
            .iter()
            .enumerate()
            .filter(|(_, p)| {
                if filter_lower.is_empty() {
                    return true;
                }
                p.name.to_lowercase().contains(&filter_lower)
                    || p.id.to_lowercase().contains(&filter_lower)
            })
            .map(|(i, _)| i)
            .collect();
        self.selected_index = 0;
        self.scroll_offset = 0;
    }

    /// Set the config status for a provider.
    pub fn set_config_status(
        &mut self,
        provider_id: impl Into<String>,
        status: ProviderConfigStatus,
    ) {
        self.config_status.insert(provider_id.into(), status);
    }

    fn adjust_scroll(&mut self) {
        if self.selected_index < self.scroll_offset {
            self.scroll_offset = self.selected_index;
        } else if self.selected_index >= self.scroll_offset + self.visible_height {
            self.scroll_offset = self.selected_index - self.visible_height + 1;
        }
    }

    /// Render the OAuth selector as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let border = "─".repeat(60);
        let mut lines = Vec::new();

        lines.push(border.clone());
        lines.push(String::new()); // spacer

        // Title
        let title = match self.mode {
            OAuthSelectorMode::Login => "Select provider to configure:",
            OAuthSelectorMode::Logout => "Select provider to logout:",
        };
        lines.push(format!("\x1b[1m\x1b[36m{}\x1b[0m", title));
        lines.push(String::new()); // spacer

        // Search input
        lines.push(format!("Search: {}", self.filter));
        lines.push(String::new()); // spacer

        // Provider list
        let end = (self.scroll_offset + self.visible_height).min(self.filtered_indices.len());
        for vi in self.scroll_offset..end {
            if let Some(&pi) = self.filtered_indices.get(vi) {
                if let Some(provider) = self.providers.get(pi) {
                    let is_selected = vi == self.selected_index;

                    // Status indicator
                    let status_str = match self.config_status.get(&provider.id) {
                        Some(ProviderConfigStatus::Configured { label }) => {
                            format!("\x1b[32m ✓ {}\x1b[0m", label)
                        }
                        Some(ProviderConfigStatus::PartiallyConfigured { label }) => {
                            format!("\x1b[33m • {}\x1b[0m", label)
                        }
                        Some(ProviderConfigStatus::Unconfigured) | None => {
                            if provider.auth_type == AuthType::ApiKey {
                                "\x1b[2m • unconfigured\x1b[0m".to_string()
                            } else {
                                "\x1b[2m • unconfigured\x1b[0m".to_string()
                            }
                        }
                    };

                    if is_selected {
                        lines.push(format!("\x1b[36m→ {}\x1b[0m{}", provider.name, status_str));
                    } else {
                        lines.push(format!("  \x1b[37m{}\x1b[0m{}", provider.name, status_str));
                    }
                }
            }
        }

        // Scroll indicator
        if self.filtered_indices.len() > self.visible_height {
            lines.push(format!(
                "\x1b[2m  ({}/{})\x1b[0m",
                self.selected_index + 1,
                self.filtered_indices.len()
            ));
        }

        // Empty state
        if self.filtered_indices.is_empty() {
            let msg = if self.providers.is_empty() {
                match self.mode {
                    OAuthSelectorMode::Login => "No providers available",
                    OAuthSelectorMode::Logout => "No providers logged in. Use /login first.",
                }
            } else {
                "No matching providers"
            };
            lines.push(format!("\x1b[2m  {}\x1b[0m", msg));
        }

        lines.push(String::new()); // spacer
        lines.push(border);

        lines
    }
}

/// Loading indicator with border and status.
///
/// Displays a spinner-style loading message inside bordered area,
/// with an optional cancel hint.
#[derive(Debug, Clone)]
pub struct BorderedLoader {
    /// Loading message.
    pub message: String,
    /// Whether the loader can be cancelled.
    pub cancellable: bool,
    /// Spinner frame index (cycles 0..4).
    pub spinner_frame: usize,
}

impl BorderedLoader {
    /// Create a new bordered loader.
    pub fn new(message: impl Into<String>, cancellable: bool) -> Self {
        Self {
            message: message.into(),
            cancellable,
            spinner_frame: 0,
        }
    }

    /// Advance the spinner by one frame.
    pub fn tick(&mut self) {
        self.spinner_frame = (self.spinner_frame + 1) % 4;
    }

    /// Get the current spinner character.
    pub fn spinner_char(&self) -> &'static str {
        match self.spinner_frame {
            0 => "⠋",
            1 => "⠙",
            2 => "⠹",
            3 => "⠸",
            _ => "⠋",
        }
    }

    /// Render the bordered loader as a vector of lines.
    pub fn render(&self) -> Vec<String> {
        let border = "─".repeat(60);
        let mut lines = Vec::new();

        lines.push(format!("\x1b[90m{}\x1b[0m", border));

        // Loading line with spinner
        lines.push(format!(
            "\x1b[36m{} {}\x1b[0m",
            self.spinner_char(),
            self.message
        ));

        // Cancel hint
        if self.cancellable {
            lines.push(String::new());
            lines.push(format!("\x1b[2mEsc: cancel\x1b[0m"));
        }

        lines.push(format!("\x1b[90m{}\x1b[0m", border));

        lines
    }
}

// ── Armin Component ──────────────────────────────────────────────────────────

/// XBM image data: 31x36 pixels, LSB first, 1=background, 0=foreground
const ARMIN_WIDTH: usize = 31;
const ARMIN_HEIGHT: usize = 36;
const ARMIN_BYTES_PER_ROW: usize = (ARMIN_WIDTH + 7) / 8; // ceil(31/8) = 4
const ARMIN_DISPLAY_HEIGHT: usize = (ARMIN_HEIGHT + 1) / 2; // half-block rendering

const ARMIN_BITS: [u8; 144] = [
    0xff, 0xff, 0xff, 0x7f, 0xff, 0xf0, 0xff, 0x7f, 0xff, 0xed, 0xff, 0x7f, 0xff, 0xdb, 0xff, 0x7f,
    0xff, 0xb7, 0xff, 0x7f, 0xff, 0x77, 0xfe, 0x7f, 0x3f, 0xf8, 0xfe, 0x7f, 0xdf, 0xff, 0xfe, 0x7f,
    0xdf, 0x3f, 0xfc, 0x7f, 0x9f, 0xc3, 0xfb, 0x7f, 0x6f, 0xfc, 0xf4, 0x7f, 0xf7, 0x0f, 0xf7, 0x7f,
    0xf7, 0xff, 0xf7, 0x7f, 0xf7, 0xff, 0xe3, 0x7f, 0xf7, 0x07, 0xe8, 0x7f, 0xef, 0xf8, 0x67, 0x70,
    0x0f, 0xff, 0xbb, 0x6f, 0xf1, 0x00, 0xd0, 0x5b, 0xfd, 0x3f, 0xec, 0x53, 0xc1, 0xff, 0xef, 0x57,
    0x9f, 0xfd, 0xee, 0x5f, 0x9f, 0xfc, 0xae, 0x5f, 0x1f, 0x78, 0xac, 0x5f, 0x3f, 0x00, 0x50, 0x6c,
    0x7f, 0x00, 0xdc, 0x77, 0xff, 0xc0, 0x3f, 0x78, 0xff, 0x01, 0xf8, 0x7f, 0xff, 0x03, 0x9c, 0x78,
    0xff, 0x07, 0x8c, 0x7c, 0xff, 0x0f, 0xce, 0x78, 0xff, 0xff, 0xcf, 0x7f, 0xff, 0xff, 0xcf, 0x78,
    0xff, 0xff, 0xdf, 0x78, 0xff, 0xff, 0xdf, 0x7d, 0xff, 0xff, 0x3f, 0x7e, 0xff, 0xff, 0xff, 0x7f,
];

/// Get pixel at (x, y): true = foreground, false = background
fn armin_get_pixel(x: usize, y: usize) -> bool {
    if y >= ARMIN_HEIGHT {
        return false;
    }
    let byte_index = y * ARMIN_BYTES_PER_ROW + x / 8;
    let bit_index = x % 8;
    (ARMIN_BITS[byte_index] >> bit_index) & 1 == 0
}

/// Get the character for a cell using half-block rendering (2 vertical pixels)
fn armin_get_char(x: usize, row: usize) -> char {
    let upper = armin_get_pixel(x, row * 2);
    let lower = armin_get_pixel(x, row * 2 + 1);
    match (upper, lower) {
        (true, true) => '█',
        (true, false) => '▀',
        (false, true) => '▄',
        (false, false) => ' ',
    }
}

/// Build the final image grid of characters
fn armin_build_final_grid() -> Vec<Vec<char>> {
    let mut grid = Vec::with_capacity(ARMIN_DISPLAY_HEIGHT);
    for row in 0..ARMIN_DISPLAY_HEIGHT {
        let mut line = Vec::with_capacity(ARMIN_WIDTH);
        for x in 0..ARMIN_WIDTH {
            line.push(armin_get_char(x, row));
        }
        grid.push(line);
    }
    grid
}

/// ArminComponent – renders XBM art with a "ARMIN SAYS HI" message.
///
/// This is a static render (no animation in the Rust port).
/// The final image is drawn in accent color with the text message below.
pub struct ArminComponent {
    final_grid: Vec<Vec<char>>,
    cached_lines: Vec<String>,
    cached_width: usize,
}

impl ArminComponent {
    pub fn new() -> Self {
        Self {
            final_grid: armin_build_final_grid(),
            cached_lines: Vec::new(),
            cached_width: 0,
        }
    }

    /// Render the component to lines at the given width
    pub fn render(&mut self, width: usize) -> Vec<String> {
        if width == self.cached_width && !self.cached_lines.is_empty() {
            return self.cached_lines.clone();
        }

        let accent = "\x1b[38;5;75m"; // light blue accent
        let reset = "\x1b[0m";
        let padding = 1;
        let available = width.saturating_sub(padding);

        let mut lines = Vec::new();

        for row in &self.final_grid {
            let chars: String = row.iter().take(available).collect();
            let pad_right = available.saturating_sub(chars.len());
            lines.push(format!(
                " {}{}{}{}",
                accent,
                chars,
                " ".repeat(pad_right),
                reset
            ));
        }

        // "ARMIN SAYS HI" message
        let message = "ARMIN SAYS HI";
        let msg_pad = available.saturating_sub(message.len());
        lines.push(format!(
            " {}{}{}{}",
            accent,
            message,
            " ".repeat(msg_pad),
            reset
        ));

        self.cached_lines = lines;
        self.cached_width = width;
        self.cached_lines.clone()
    }
}

impl Default for ArminComponent {
    fn default() -> Self {
        Self::new()
    }
}

// ── Daxnuts Component ─────────────────────────────────────────────────────────

/// DaxnutsComponent – decorative RGB image rendered with half-block characters.
///
/// Ported from the TypeScript daxnuts.ts easter-egg component.
/// Uses 24-bit color escape codes for pixel-accurate rendering.
pub struct DaxnutsComponent {
    image_lines: Vec<String>,
}

impl DaxnutsComponent {
    pub fn new() -> Self {
        Self {
            image_lines: build_dax_image(),
        }
    }

    /// Render the component at the given terminal width
    pub fn render(&self, width: usize) -> Vec<String> {
        let reset = "\x1b[0m";
        let accent = "\x1b[38;5;75m"; // light blue
        let success = "\x1b[32m";
        let muted = "\x1b[90m";
        let dim = "\x1b[2m";
        let link = "\x1b[36m"; // cyan for links

        let mut lines: Vec<String> = Vec::new();
        lines.push(String::new());

        for img_line in &self.image_lines {
            lines.push(center_ansi(img_line, width));
        }

        lines.push(String::new());
        lines.push(center_ansi(
            &format!("{}Free Kimi K2.5 via OpenCode Zen{}", accent, reset),
            width,
        ));
        lines.push(center_ansi(
            &format!("{}\"Powered by daxnuts\"{}", success, reset),
            width,
        ));
        lines.push(center_ansi(&format!("{}— @thdxr{}", muted, reset), width));

        lines.push(String::new());
        lines.push(center_ansi(&format!("{}Try OpenCode{}", dim, reset), width));
        lines.push(center_ansi(
            &format!("{}https://mistral.ai/news/mistral-vibe-2-0{}", link, reset),
            width,
        ));
        lines.push(String::new());

        lines
    }
}

impl Default for DaxnutsComponent {
    fn default() -> Self {
        Self::new()
    }
}

/// DAX image hex-encoded RGB data (32x32 pixels, 3 bytes per pixel)
const DAX_HEX: &str = " ";

const DAX_IMG_WIDTH: usize = 32;
const DAX_IMG_HEIGHT: usize = 32;

/// Parse the hex-encoded RGB image into pixel data
fn parse_dax_pixels() -> Vec<Vec<(u8, u8, u8)>> {
    let hex = DAX_HEX;
    let mut pixels = Vec::with_capacity(DAX_IMG_HEIGHT);
    for y in 0..DAX_IMG_HEIGHT {
        let mut row = Vec::with_capacity(DAX_IMG_WIDTH);
        for x in 0..DAX_IMG_WIDTH {
            let idx = (y * DAX_IMG_WIDTH + x) * 6;
            let r = u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap_or(0);
            let g = u8::from_str_radix(&hex[idx + 2..idx + 4], 16).unwrap_or(0);
            let b = u8::from_str_radix(&hex[idx + 4..idx + 6], 16).unwrap_or(0);
            row.push((r, g, b));
        }
        pixels.push(row);
    }
    pixels
}

/// Build the image lines using half-block ▄ with fg=bottom, bg=top pixel
fn build_dax_image() -> Vec<String> {
    let pixels = parse_dax_pixels();
    let mut lines = Vec::new();

    for row in (0..DAX_IMG_HEIGHT).step_by(2) {
        let mut line = String::new();
        for x in 0..DAX_IMG_WIDTH {
            let (tr, tg, tb) = pixels[row][x];
            let (br, bg_val, bb) = pixels
                .get(row + 1)
                .and_then(|r| r.get(x))
                .copied()
                .unwrap_or((tr, tg, tb));
            // fg = bottom pixel, bg = top pixel
            line.push_str(&format!(
                "\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m▄",
                br, bg_val, bb, tr, tg, tb
            ));
        }
        line.push_str("\x1b[0m");
        lines.push(line);
    }
    lines
}

/// Center a string that may contain ANSI escape codes within the given width
fn center_ansi(s: &str, width: usize) -> String {
    // Count visible characters (strip ANSI) - use chars().count() for Unicode
    let visible_len = strip_ansi(s).chars().count();
    let left = if width > visible_len {
        (width - visible_len) / 2
    } else {
        0
    };
    format!("{}{}", " ".repeat(left), s)
}

// ── Dynamic Border ────────────────────────────────────────────────────────────

/// Dynamic border that adjusts to viewport width.
///
/// Renders a horizontal line using box-drawing characters, optionally colored.
pub struct DynamicBorder {
    /// Optional color function name – if None, uses default dim styling
    color_fn: Option<BorderStyle>,
}

/// Border styling options
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BorderStyle {
    /// Accent color (blue/cyan)
    Accent,
    /// Muted/dim color
    Muted,
    /// Success color (green)
    Success,
    /// Error color (red)
    Error,
    /// Custom 256-color index
    Custom(u8),
}

impl DynamicBorder {
    /// Create a new dynamic border with default (dim) styling
    pub fn new() -> Self {
        Self { color_fn: None }
    }

    /// Create with a specific border style
    pub fn with_style(style: BorderStyle) -> Self {
        Self {
            color_fn: Some(style),
        }
    }

    /// Create with accent color
    pub fn accent() -> Self {
        Self::with_style(BorderStyle::Accent)
    }

    /// Render the border at the given width
    pub fn render(&self, width: usize) -> Vec<String> {
        let line = "─".repeat(width.max(1));
        let colored = match self.color_fn {
            Some(BorderStyle::Accent) => format!("\x1b[38;5;75m{}\x1b[0m", line),
            Some(BorderStyle::Muted) => format!("\x1b[90m{}\x1b[0m", line),
            Some(BorderStyle::Success) => format!("\x1b[32m{}\x1b[0m", line),
            Some(BorderStyle::Error) => format!("\x1b[31m{}\x1b[0m", line),
            Some(BorderStyle::Custom(c)) => format!("\x1b[38;5;{}m{}\x1b[0m", c, line),
            None => format!("\x1b[90m{}\x1b[0m", line),
        };
        vec![colored]
    }
}

impl Default for DynamicBorder {
    fn default() -> Self {
        Self::new()
    }
}

// ── Earendil Announcement ────────────────────────────────────────────────────

/// Announcement/notification display component.
///
/// Renders a framed announcement with a title, optional body text, and borders.
/// Ported from the TypeScript earendil-announcement component.
pub struct EarendilAnnouncement {
    /// Title of the announcement (bold, accent color)
    pub title: String,
    /// Body lines to display under the title
    pub body: Vec<String>,
    /// Optional link to display
    pub link: Option<String>,
    /// Border style
    pub border_style: BorderStyle,
}

impl EarendilAnnouncement {
    /// Create a new announcement with title and body
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            body: Vec::new(),
            link: None,
            border_style: BorderStyle::Accent,
        }
    }

    /// Create the default Earendil announcement
    pub fn earendil_default() -> Self {
        Self {
            title: "pi has joined Earendil".to_string(),
            body: vec![
                "Read the blog post:".to_string(),
                "https://mariozechner.at/posts/2026-04-08-ive-sold-out/".to_string(),
            ],
            link: Some("https://mariozechner.at/posts/2026-04-08-ive-sold-out/".to_string()),
            border_style: BorderStyle::Accent,
        }
    }

    /// Add a body line
    pub fn add_body(&mut self, line: impl Into<String>) {
        self.body.push(line.into());
    }

    /// Set link
    pub fn with_link(mut self, link: impl Into<String>) -> Self {
        self.link = Some(link.into());
        self
    }

    /// Render the announcement at the given width
    pub fn render(&self, width: usize) -> Vec<String> {
        let border = DynamicBorder::with_style(self.border_style);
        let accent = "\x1b[38;5;75m";
        let muted = "\x1b[90m";
        let link_color = "\x1b[36m";
        let bold = "\x1b[1m";
        let reset = "\x1b[0m";

        let mut lines = Vec::new();

        // Top border
        lines.extend(border.render(width));

        // Title (bold, accent)
        lines.push(format!(" {}{}{}{}", accent, bold, self.title, reset));

        // Spacer
        lines.push(String::new());

        // Body lines
        for body_line in &self.body {
            // Detect if it's a URL
            if body_line.starts_with("http://") || body_line.starts_with("https://") {
                lines.push(format!(" {}{}{}", link_color, body_line, reset));
            } else {
                lines.push(format!(" {}{}{}", muted, body_line, reset));
            }
        }

        // Spacer
        lines.push(String::new());

        // Bottom border
        lines.extend(border.render(width));

        lines
    }
}

// ── Enhanced Tool Execution ──────────────────────────────────────────────────

/// Extended tool execution with timing, progress, and rich status display.
///
/// Enhances the basic `ToolExecution` struct with:
/// - Elapsed time tracking
/// - Progress indicators
/// - Collapsible sections for arguments and output
/// - Timing information display
/// - Detailed status rendering
#[derive(Debug, Clone)]
pub struct ToolExecutionDisplay {
    /// Tool name
    pub tool_name: String,
    /// Tool call ID
    pub tool_call_id: String,
    /// Parsed arguments
    pub arguments: serde_json::Value,
    /// Current execution state
    pub state: ToolExecutionState,
    /// Result (if complete)
    pub result: Option<ToolResult>,
    /// Whether the display is expanded
    pub expanded: bool,
    /// Whether to show images
    pub show_images: bool,
    /// Max image width in cells
    pub image_width_cells: usize,
    /// When execution started
    pub started_at: Option<std::time::Instant>,
    /// When execution completed
    pub completed_at: Option<std::time::Instant>,
    /// Whether arguments parsing is complete
    pub args_complete: bool,
    /// Whether the call is still partial (streaming args)
    pub is_partial: bool,
}

impl ToolExecutionDisplay {
    /// Create a new tool execution display
    pub fn new(
        tool_name: impl Into<String>,
        tool_call_id: impl Into<String>,
        args: serde_json::Value,
    ) -> Self {
        Self {
            tool_name: tool_name.into(),
            tool_call_id: tool_call_id.into(),
            arguments: args,
            state: ToolExecutionState::Pending,
            result: None,
            expanded: false,
            show_images: true,
            image_width_cells: 60,
            started_at: None,
            completed_at: None,
            args_complete: false,
            is_partial: true,
        }
    }

    /// Mark execution as started
    pub fn start(&mut self) {
        self.state = ToolExecutionState::Running;
        self.started_at = Some(std::time::Instant::now());
    }

    /// Mark arguments as complete
    pub fn set_args_complete(&mut self) {
        self.args_complete = true;
        self.is_partial = false;
    }

    /// Update arguments (for streaming)
    pub fn update_args(&mut self, args: serde_json::Value) {
        self.arguments = args;
    }

    /// Complete with result
    pub fn complete(&mut self, result: ToolResult) {
        self.state = if result.is_error {
            ToolExecutionState::Error
        } else {
            ToolExecutionState::Success
        };
        self.completed_at = Some(std::time::Instant::now());
        self.result = Some(result);
    }

    /// Set expanded state
    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Toggle expanded
    pub fn toggle_expanded(&mut self) {
        self.expanded = !self.expanded;
    }

    /// Get elapsed time as a formatted string
    pub fn elapsed_str(&self) -> String {
        let start = match self.started_at {
            Some(s) => s,
            None => return String::new(),
        };
        let end = self.completed_at.unwrap_or_else(std::time::Instant::now);
        let dur = end.duration_since(start);
        let ms = dur.as_millis();
        if ms < 1000 {
            format!("{}ms", ms)
        } else {
            format!("{:.1}s", dur.as_secs_f64())
        }
    }

    /// Format arguments for display
    pub fn format_arguments(&self) -> String {
        if self.arguments.is_null() {
            String::new()
        } else {
            serde_json::to_string_pretty(&self.arguments)
                .unwrap_or_else(|_| self.arguments.to_string())
        }
    }

    /// Render the full tool execution display
    pub fn render(&self) -> Vec<String> {
        let mut lines = Vec::new();
        let reset = "\x1b[0m";

        // Determine colors based on state
        let (bg_color, fg_color, status_icon, status_label) = match self.state {
            ToolExecutionState::Pending => ("\x1b[48;5;240m", "\x1b[38;5;250m", "○", "pending"),
            ToolExecutionState::Running => ("\x1b[48;5;239m", "\x1b[38;5;250m", "◐", "running"),
            ToolExecutionState::Success => ("\x1b[48;5;28m", "\x1b[38;5;255m", "●", "done"),
            ToolExecutionState::Error => ("\x1b[48;5;196m", "\x1b[38;5;255m", "✗", "error"),
        };

        // ── Header line: status icon + tool name + elapsed time ──
        let elapsed = self.elapsed_str();
        let header = if elapsed.is_empty() {
            format!(
                "{} {} {}{}{} {}{}{}",
                bg_color,
                status_icon,
                fg_color,
                "\x1b[1m",
                self.tool_name,
                fg_color,
                status_label,
                reset
            )
        } else {
            format!(
                "{} {} {}{}{} {}{}{} \x1b[90m{}{}",
                bg_color,
                status_icon,
                fg_color,
                "\x1b[1m",
                self.tool_name,
                fg_color,
                status_label,
                "\x1b[90m",
                elapsed,
                reset
            )
        };
        lines.push(header);

        // ── Arguments section ──
        let args_str = self.format_arguments();
        if !args_str.is_empty() {
            if self.expanded || args_str.len() < 200 {
                for arg_line in args_str.lines() {
                    lines.push(format!("  \x1b[38;5;246m{}{}", arg_line, reset));
                }
            } else {
                // Show first few lines, then truncation
                let arg_lines: Vec<&str> = args_str.lines().collect();
                let show = 3.min(arg_lines.len());
                for line in &arg_lines[..show] {
                    lines.push(format!("  \x1b[38;5;246m{}{}", line, reset));
                }
                if arg_lines.len() > show {
                    lines.push(format!(
                        "  \x1b[90m... ({} more lines, press → to expand){}",
                        arg_lines.len() - show,
                        reset
                    ));
                }
            }
        }

        // ── Partial indicator ──
        if self.is_partial && self.state == ToolExecutionState::Pending {
            lines.push(format!("  \x1b[90m(receiving arguments...){}", reset));
        }

        // ── Result section ──
        if let Some(ref result) = self.result {
            let result_fg = if result.is_error {
                "\x1b[31m"
            } else {
                "\x1b[38;5;250m"
            };

            if let Some(text) = result.get_text() {
                if self.expanded {
                    for text_line in text.lines() {
                        lines.push(format!("  {}{}{}", result_fg, text_line, reset));
                    }
                } else {
                    let truncated = truncate_text(&text, 500);
                    for text_line in truncated.lines() {
                        lines.push(format!("  {}{}{}", result_fg, text_line, reset));
                    }
                    if text.len() > 500 {
                        lines.push(format!(
                            "  \x1b[90m(... {} more chars, press → to expand){}",
                            text.len() - 500,
                            reset
                        ));
                    }
                }
            }

            // Image count
            if result.has_images() && self.show_images {
                lines.push(format!(
                    "  \x1b[38;5;246m[{} image{}]{}",
                    result.image_count(),
                    if result.image_count() == 1 { "" } else { "s" },
                    reset
                ));
            }
        } else if self.state == ToolExecutionState::Running {
            lines.push(format!("  \x1b[90m⏳ executing...{}", reset));
        }

        lines
    }

    /// Render as a single string (joined with newlines)
    pub fn render_to_string(&self) -> String {
        self.render().join("\n")
    }
}

// ── Interactive Message Components ─────────────────────────────────────────
//
// Ported from pi-mono/packages/coding-agent/src/modes/interactive/components/
// These components handle user messages, skill invocations, diff rendering,
// keybinding hints, footer, visual truncation, image selection, and countdown.

/// OSC 133 prompt escape sequences for terminal integration
const OSC133_ZONE_START: &str = "\x1b]133;A\x07";
const OSC133_ZONE_END: &str = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL: &str = "\x1b]133;C\x07";

/// ANSI color helpers using theme-like colors for TUI rendering.
/// These are inline helpers for components that produce `Vec<String>` output.
#[allow(dead_code)]
mod ansi {
    /// Red foreground
    pub fn red(text: &str) -> String {
        format!("\x1b[31m{}\x1b[0m", text)
    }

    /// Green foreground
    pub fn green(text: &str) -> String {
        format!("\x1b[32m{}\x1b[0m", text)
    }

    /// Yellow foreground
    pub fn yellow(text: &str) -> String {
        format!("\x1b[33m{}\x1b[0m", text)
    }

    /// Blue foreground
    pub fn blue(text: &str) -> String {
        format!("\x1b[34m{}\x1b[0m", text)
    }

    /// Cyan foreground
    pub fn cyan(text: &str) -> String {
        format!("\x1b[36m{}\x1b[0m", text)
    }

    /// Magenta/purple foreground
    pub fn magenta(text: &str) -> String {
        format!("\x1b[35m{}\x1b[0m", text)
    }

    /// Bold text
    pub fn bold(text: &str) -> String {
        format!("\x1b[1m{}\x1b[0m", text)
    }

    /// Italic text
    pub fn italic(text: &str) -> String {
        format!("\x1b[3m{}\x1b[0m", text)
    }

    /// Dim/faint text
    pub fn dim(text: &str) -> String {
        format!("\x1b[2m{}\x1b[0m", text)
    }

    /// Inverse/reverse video
    pub fn inverse(text: &str) -> String {
        format!("\x1b[7m{}\x1b[0m", text)
    }

    /// User message background (subtle blue tint)
    pub fn user_message_bg(text: &str) -> String {
        format!("\x1b[48;5;17m{}\x1b[0m", text)
    }

    /// User message text color
    pub fn user_message_text(text: &str) -> String {
        format!("\x1b[38;5;189m{}\x1b[0m", text)
    }

    /// Skill/custom message label color (purple)
    pub fn custom_label(text: &str) -> String {
        format!("\x1b[38;5;183m{}\x1b[0m", text)
    }

    /// Skill/custom message text
    pub fn custom_text(text: &str) -> String {
        format!("\x1b[38;5;254m{}\x1b[0m", text)
    }

    /// Diff context line color (dim gray)
    pub fn diff_context(text: &str) -> String {
        format!("\x1b[38;5;244m{}\x1b[0m", text)
    }

    /// Diff removed line color (red)
    pub fn diff_removed(text: &str) -> String {
        format!("\x1b[31m{}\x1b[0m", text)
    }

    /// Diff added line color (green)
    pub fn diff_added(text: &str) -> String {
        format!("\x1b[32m{}\x1b[0m", text)
    }

    /// Thinking text color
    pub fn thinking_text(text: &str) -> String {
        format!("\x1b[38;5;180m{}\x1b[0m", text)
    }

    /// Muted text (for hints, labels)
    pub fn muted(text: &str) -> String {
        format!("\x1b[38;5;243m{}\x1b[0m", text)
    }

    /// Accent color (for cursors, highlights)
    pub fn accent(text: &str) -> String {
        format!("\x1b[38;5;183m{}\x1b[0m", text)
    }

    /// Error color
    pub fn error(text: &str) -> String {
        format!("\x1b[38;5;203m{}\x1b[0m", text)
    }

    /// Warning color
    pub fn warning(text: &str) -> String {
        format!("\x1b[38;5;215m{}\x1b[0m", text)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 1. UserMessageRenderer
// ─────────────────────────────────────────────────────────────────────────────

/// User message item for the message selector.
#[derive(Debug, Clone)]
pub struct UserMessageItem {
    /// Entry ID in the session.
    pub id: String,
    /// The message text.
    pub text: String,
    /// Optional timestamp.
    pub timestamp: Option<String>,
}

/// Renders a user message with optional image indicators and OSC 133 markers.
pub struct UserMessageRenderer {
    /// The message text (may contain markdown).
    pub text: String,
    /// Whether the message has associated images.
    pub has_images: bool,
    /// Whether to emit OSC 133 prompt markers.
    pub use_osc133: bool,
}

impl UserMessageRenderer {
    pub fn new(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            has_images: false,
            use_osc133: false,
        }
    }

    /// Set whether the message has images.
    pub fn with_images(mut self, has_images: bool) -> Self {
        self.has_images = has_images;
        self
    }

    /// Enable OSC 133 markers.
    pub fn with_osc133(mut self, enable: bool) -> Self {
        self.use_osc133 = enable;
        self
    }

    /// Render the user message to lines.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Image indicator
        if self.has_images {
            lines.push(ansi::cyan(&format!("  📎 (has images)")));
        }

        // Render text with user message styling
        let rendered = ansi::user_message_text(&self.text);
        for line in rendered.lines() {
            // Truncate to width
            let truncated = if line.chars().count() > width {
                let mut s = String::new();
                let mut w = 0;
                for c in line.chars() {
                    if w + 1 > width {
                        s.push_str("...");
                        break;
                    }
                    s.push(c);
                    w += 1;
                }
                s
            } else {
                line.to_string()
            };
            lines.push(ansi::user_message_bg(&truncated));
        }

        // Wrap with OSC 133 markers
        if self.use_osc133 && !lines.is_empty() {
            lines[0] = format!("{}{}", OSC133_ZONE_START, lines[0]);
            let last = lines.len() - 1;
            lines[last] = format!("{}{}{}", OSC133_ZONE_END, OSC133_ZONE_FINAL, lines[last]);
        }

        lines
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 2. UserMessageSelector
// ─────────────────────────────────────────────────────────────────────────────

/// State for selecting/editing previous user messages.
#[derive(Debug, Clone)]
pub struct UserMessageSelector {
    /// Messages to choose from (oldest to newest).
    pub messages: Vec<UserMessageItem>,
    /// Currently selected index.
    pub selected_index: usize,
    /// Maximum visible messages.
    pub max_visible: usize,
}

impl UserMessageSelector {
    /// Create a new selector with messages.
    pub fn new(messages: Vec<UserMessageItem>) -> Self {
        let selected_index = messages.len().saturating_sub(1);
        Self {
            messages,
            selected_index,
            max_visible: 10,
        }
    }

    /// Create with initial selected ID.
    pub fn with_initial_id(mut self, id: &str) -> Self {
        if let Some(idx) = self.messages.iter().position(|m| m.id == id) {
            self.selected_index = idx;
        }
        self
    }

    /// Move selection up (to older messages), wraps to bottom.
    pub fn move_up(&mut self) {
        if self.selected_index == 0 {
            self.selected_index = self.messages.len().saturating_sub(1);
        } else {
            self.selected_index -= 1;
        }
    }

    /// Move selection down (to newer messages), wraps to top.
    pub fn move_down(&mut self) {
        if self.messages.is_empty() {
            return;
        }
        if self.selected_index >= self.messages.len() - 1 {
            self.selected_index = 0;
        } else {
            self.selected_index += 1;
        }
    }

    /// Get the currently selected message.
    pub fn selected(&self) -> Option<&UserMessageItem> {
        self.messages.get(self.selected_index)
    }

    /// Render the selector as lines.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Header
        lines.push(String::new()); // blank
        lines.push(ansi::bold("Fork from Message"));
        lines.push(ansi::muted(
            "Select a user message to copy the active path up to that point into a new session",
        ));
        lines.push(String::new()); // blank
        lines.push("─".repeat(width.min(60)));
        lines.push(String::new()); // blank

        if self.messages.is_empty() {
            lines.push(ansi::muted("  No user messages found"));
            return lines;
        }

        // Calculate visible range with scrolling
        let start_index = if self.selected_index < self.max_visible / 2 {
            0
        } else if self.selected_index + self.max_visible / 2 >= self.messages.len() {
            self.messages.len().saturating_sub(self.max_visible)
        } else {
            self.selected_index - self.max_visible / 2
        };
        let end_index = (start_index + self.max_visible).min(self.messages.len());

        for i in start_index..end_index {
            let msg = &self.messages[i];
            let is_selected = i == self.selected_index;

            // Normalize to single line
            let normalized: String = msg.text.chars().filter(|c| *c != '\n').collect();
            let normalized = normalized.trim();

            // Cursor + message
            let cursor = if is_selected {
                ansi::accent("› ")
            } else {
                "  ".to_string()
            };
            let max_msg_width = width.saturating_sub(2);
            let truncated_msg = truncate_str(normalized, max_msg_width);
            let message_line = if is_selected {
                format!("{}{}", cursor, ansi::bold(&truncated_msg))
            } else {
                format!("{}{}", cursor, truncated_msg)
            };
            lines.push(message_line);

            // Metadata
            let position = i + 1;
            let metadata = format!("  Message {} of {}", position, self.messages.len());
            lines.push(ansi::muted(&metadata));
            lines.push(String::new()); // blank between messages
        }

        // Scroll indicator
        if start_index > 0 || end_index < self.messages.len() {
            let scroll_info = format!("  ({}/{})", self.selected_index + 1, self.messages.len());
            lines.push(ansi::muted(&scroll_info));
        }

        // Bottom border
        lines.push(String::new());
        lines.push("─".repeat(width.min(60)));

        lines
    }
}

/// Truncate a string to a maximum visible character count, handling ANSI escape codes.
fn truncate_str(s: &str, max_width: usize) -> String {
    let mut result = String::with_capacity(s.len());
    let mut width = 0;
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\x1b' {
            // ANSI escape sequence - include without counting width
            result.push(c);
            while let Some(&next) = chars.peek() {
                result.push(chars.next().unwrap());
                if next.is_ascii_alphabetic() {
                    break;
                }
            }
            continue;
        }

        let char_w = if is_wide_char(c) { 2 } else { 1 };
        if width + char_w > max_width {
            if width >= 3 {
                result.truncate(result.len() - 3);
                result.push_str("...");
            }
            break;
        }
        result.push(c);
        width += char_w;
    }
    result
}

/// Check if a character is wide (CJK, emoji, etc.).
fn is_wide_char(c: char) -> bool {
    let code = c as u32;
    (0xFF01..=0xFF5E).contains(&code)
        || (0x4E00..=0x9FFF).contains(&code)
        || (0x3400..=0x4DBF).contains(&code)
        || (0xFE30..=0xFE4F).contains(&code)
        || (0xFF00..=0xFFEF).contains(&code)
        || (0x3000..=0x303F).contains(&code)
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. SkillInvocationMessage
// ─────────────────────────────────────────────────────────────────────────────

/// Parsed skill block data.
#[derive(Debug, Clone)]
pub struct ParsedSkillBlock {
    /// Skill name.
    pub name: String,
    /// Skill content/arguments.
    pub content: String,
}

/// Renders a skill invocation with collapsed/expanded state.
pub struct SkillInvocationMessage {
    /// The skill block to render.
    pub skill_block: ParsedSkillBlock,
    /// Whether the block is expanded.
    pub expanded: bool,
    /// Key hint for expanding.
    pub expand_key_hint: String,
}

impl SkillInvocationMessage {
    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            skill_block: ParsedSkillBlock {
                name: name.into(),
                content: content.into(),
            },
            expanded: false,
            expand_key_hint: "Enter".to_string(),
        }
    }

    /// Set expanded state.
    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Toggle expanded state.
    pub fn toggle_expanded(&mut self) {
        self.expanded = !self.expanded;
    }

    /// Render to lines.
    pub fn render(&self, _width: usize) -> Vec<String> {
        if self.expanded {
            // Expanded: label + skill name header + full content
            let label = ansi::custom_label(&ansi::bold("[skill]"));
            let name_line = ansi::bold(&format!("**{}**", self.skill_block.name));
            let content_rendered = render_markdown(&self.skill_block.content);
            vec![
                format!("{} {}", label, name_line),
                ansi::custom_text(&content_rendered),
            ]
        } else {
            // Collapsed: single line
            let line = format!(
                "{} {} {}",
                ansi::custom_label(&ansi::bold("[skill]")),
                ansi::custom_text(&self.skill_block.name),
                ansi::dim(&format!("({} to expand)", self.expand_key_hint))
            );
            vec![line]
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 4. Enhanced DiffRenderer
// ─────────────────────────────────────────────────────────────────────────────

/// Render unified diffs with color (red/green) and optional intra-line highlighting.
pub struct DiffRenderer {
    /// Enable word-level change highlighting.
    pub word_diff: bool,
}

impl DiffRenderer {
    pub fn new() -> Self {
        Self { word_diff: true }
    }

    /// Create without word diff.
    pub fn new_simple() -> Self {
        Self { word_diff: false }
    }

    /// Render a diff string with colored lines.
    pub fn render(&self, diff_text: &str, _file_path: Option<&str>) -> String {
        let lines = diff_text.lines();
        let mut result = Vec::new();
        let line_iter: Vec<&str> = lines.collect();
        let mut i = 0;

        while i < line_iter.len() {
            let line = line_iter[i];
            let parsed = parse_diff_line_ts(line);

            match parsed {
                None => {
                    // Non-diff line (e.g. hunk header or plain text)
                    if line.starts_with("@@") {
                        result.push(ansi::cyan(line));
                    } else {
                        result.push(ansi::diff_context(line));
                    }
                    i += 1;
                }
                Some(DiffLineParsed::Context { line_num, content }) => {
                    let content = replace_tabs(&content);
                    result.push(ansi::diff_context(&format!(" {} {}", line_num, content)));
                    i += 1;
                }
                Some(DiffLineParsed::Removed { line_num, content }) => {
                    // Collect consecutive removed lines
                    let mut removed = vec![(line_num, content)];
                    i += 1;
                    while i < line_iter.len() {
                        if let Some(DiffLineParsed::Removed { line_num, content }) =
                            parse_diff_line_ts(line_iter[i])
                        {
                            removed.push((line_num, content));
                            i += 1;
                        } else {
                            break;
                        }
                    }

                    // Collect consecutive added lines
                    let mut added = Vec::new();
                    while i < line_iter.len() {
                        if let Some(DiffLineParsed::Added { line_num, content }) =
                            parse_diff_line_ts(line_iter[i])
                        {
                            added.push((line_num, content));
                            i += 1;
                        } else {
                            break;
                        }
                    }

                    // Intra-line diff only for 1:1 (single modification)
                    if self.word_diff && removed.len() == 1 && added.len() == 1 {
                        let (rem_num, rem_content) = &removed[0];
                        let (add_num, add_content) = &added[0];
                        let (rem_line, add_line) = render_intra_line_diff(
                            &replace_tabs(rem_content),
                            &replace_tabs(add_content),
                        );
                        result.push(ansi::diff_removed(&format!("-{} {}", rem_num, rem_line)));
                        result.push(ansi::diff_added(&format!("+{} {}", add_num, add_line)));
                    } else {
                        for (num, content) in &removed {
                            result.push(ansi::diff_removed(&format!(
                                "-{} {}",
                                num,
                                replace_tabs(content)
                            )));
                        }
                        for (num, content) in &added {
                            result.push(ansi::diff_added(&format!(
                                "+{} {}",
                                num,
                                replace_tabs(content)
                            )));
                        }
                    }
                }
                Some(DiffLineParsed::Added { line_num, content }) => {
                    let content = replace_tabs(&content);
                    result.push(ansi::diff_added(&format!("+{} {}", line_num, content)));
                    i += 1;
                }
            }
        }

        result.join("\n")
    }
}

impl Default for DiffRenderer {
    fn default() -> Self {
        Self::new()
    }
}

/// Parsed diff line from tool-output format.
#[derive(Debug)]
enum DiffLineParsed {
    Context { line_num: String, content: String },
    Added { line_num: String, content: String },
    Removed { line_num: String, content: String },
}

/// Parse a diff line in the format: "+123 content" or "-123 content" or " 123 content".
fn parse_diff_line_ts(line: &str) -> Option<DiffLineParsed> {
    let chars: Vec<char> = line.chars().collect();
    if chars.is_empty() {
        return None;
    }

    let prefix = chars[0];
    if prefix != '+' && prefix != '-' && prefix != ' ' {
        return None;
    }

    // Find where the line number ends (space after digits)
    let rest: String = chars[1..].iter().collect();
    let trimmed = rest.trim_start();

    // Try to parse: line_num followed by space then content
    if let Some(space_pos) = trimmed.find(' ') {
        let line_num = &trimmed[..space_pos];
        let content = &trimmed[space_pos + 1..];
        match prefix {
            '+' => Some(DiffLineParsed::Added {
                line_num: line_num.to_string(),
                content: content.to_string(),
            }),
            '-' => Some(DiffLineParsed::Removed {
                line_num: line_num.to_string(),
                content: content.to_string(),
            }),
            ' ' => Some(DiffLineParsed::Context {
                line_num: line_num.to_string(),
                content: content.to_string(),
            }),
            _ => None,
        }
    } else {
        // No space - just prefix with possible line number
        match prefix {
            '+' => Some(DiffLineParsed::Added {
                line_num: String::new(),
                content: trimmed.to_string(),
            }),
            '-' => Some(DiffLineParsed::Removed {
                line_num: String::new(),
                content: trimmed.to_string(),
            }),
            ' ' => Some(DiffLineParsed::Context {
                line_num: String::new(),
                content: trimmed.to_string(),
            }),
            _ => None,
        }
    }
}

/// Replace tabs with spaces for consistent rendering.
fn replace_tabs(text: &str) -> String {
    text.replace('\t', "   ")
}

/// Compute word-level diff and render with inverse on changed parts.
fn render_intra_line_diff(old_content: &str, new_content: &str) -> (String, String) {
    let old_words = split_words(old_content);
    let new_words = split_words(new_content);

    // Simple word-level diff: find common prefix and suffix
    let prefix_len = common_prefix_len(&old_words, &new_words);
    let suffix_len = common_suffix_len(&old_words[prefix_len..], &new_words[prefix_len..]);

    let old_mid_end = old_words.len().saturating_sub(suffix_len);
    let new_mid_end = new_words.len().saturating_sub(suffix_len);

    let mut removed_line = String::new();
    let mut added_line = String::new();

    // Common prefix
    for w in &old_words[..prefix_len] {
        removed_line.push_str(w);
        added_line.push_str(w);
    }

    // Changed part (inverse highlight)
    for w in &old_words[prefix_len..old_mid_end] {
        removed_line.push_str(&ansi::inverse(w));
    }
    for w in &new_words[prefix_len..new_mid_end] {
        added_line.push_str(&ansi::inverse(w));
    }

    // Common suffix
    if suffix_len > 0 {
        for w in &old_words[old_mid_end..] {
            removed_line.push_str(w);
        }
        for w in &new_words[new_mid_end..] {
            added_line.push_str(w);
        }
    }

    (removed_line, added_line)
}

/// Split text into words preserving whitespace between them.
fn split_words(text: &str) -> Vec<String> {
    let mut words = Vec::new();
    let mut current = String::new();
    let mut in_whitespace = false;

    for c in text.chars() {
        let is_ws = c.is_whitespace();
        if is_ws != in_whitespace && !current.is_empty() {
            words.push(std::mem::take(&mut current));
        }
        in_whitespace = is_ws;
        current.push(c);
    }
    if !current.is_empty() {
        words.push(current);
    }
    words
}

/// Length of common prefix between two word slices.
fn common_prefix_len<'a>(a: &[String], b: &[String]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Length of common suffix between two word slices.
fn common_suffix_len<'a>(a: &[String], b: &[String]) -> usize {
    a.iter()
        .rev()
        .zip(b.iter().rev())
        .take_while(|(x, y)| x == y)
        .count()
}

// ─────────────────────────────────────────────────────────────────────────────
// 5. KeybindingHints
// ─────────────────────────────────────────────────────────────────────────────

/// A single keybinding hint for display.
#[derive(Debug, Clone)]
pub struct KeyHint {
    /// Key sequence text (e.g. "Ctrl+C", "↑/↓").
    pub keys: String,
    /// Description of the action.
    pub description: String,
}

impl KeyHint {
    pub fn new(keys: impl Into<String>, description: impl Into<String>) -> Self {
        Self {
            keys: keys.into(),
            description: description.into(),
        }
    }
}

/// Renders a list of keyboard shortcut hints.
pub struct KeybindingHints;

impl KeybindingHints {
    /// Render a single key hint: dim keys + muted description.
    pub fn key_hint(keys: &str, description: &str) -> String {
        format!(
            "{}{}",
            ansi::dim(keys),
            ansi::muted(&format!(" {}", description))
        )
    }

    /// Render multiple hints as separate lines.
    pub fn render(hints: &[KeyHint]) -> Vec<String> {
        hints
            .iter()
            .map(|h| Self::key_hint(&h.keys, &h.description))
            .collect()
    }

    /// Render hints as a single horizontal line separated by spaces.
    pub fn render_inline(hints: &[KeyHint]) -> String {
        hints
            .iter()
            .map(|h| Self::key_hint(&h.keys, &h.description))
            .collect::<Vec<_>>()
            .join("  ")
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 6. FooterComponent (enhanced)
// ─────────────────────────────────────────────────────────────────────────────

/// Enhanced footer component data with all status fields from pi-mono.
#[derive(Debug, Clone)]
pub struct FooterComponentData {
    /// Current model name.
    pub model_name: String,
    /// Provider name.
    pub provider_name: String,
    /// Thinking level ("off", "low", "medium", "high").
    pub thinking_level: String,
    /// Session name (optional).
    pub session_name: Option<String>,
    /// Git branch.
    pub git_branch: Option<String>,
    /// Working directory (abbreviated with ~).
    pub pwd: Option<String>,
    /// Total input tokens.
    pub input_tokens: u64,
    /// Total output tokens.
    pub output_tokens: u64,
    /// Cache read tokens.
    pub cache_read_tokens: u64,
    /// Cache write tokens.
    pub cache_write_tokens: u64,
    /// Total cost in USD.
    pub total_cost: f64,
    /// Whether using OAuth subscription.
    pub using_subscription: bool,
    /// Context window usage percent (0.0 - 100.0).
    pub context_window_pct: f64,
    /// Context window size.
    pub context_window: u64,
    /// Whether auto-compact is enabled.
    pub auto_compact_enabled: bool,
    /// Number of available providers.
    pub available_provider_count: usize,
    /// Extension status messages.
    pub extension_statuses: Vec<(String, String)>,
}

impl Default for FooterComponentData {
    fn default() -> Self {
        Self {
            model_name: String::new(),
            provider_name: String::new(),
            thinking_level: "off".to_string(),
            session_name: None,
            git_branch: None,
            pwd: None,
            input_tokens: 0,
            output_tokens: 0,
            cache_read_tokens: 0,
            cache_write_tokens: 0,
            total_cost: 0.0,
            using_subscription: false,
            context_window_pct: 0.0,
            context_window: 0,
            auto_compact_enabled: true,
            available_provider_count: 1,
            extension_statuses: Vec::new(),
        }
    }
}

impl FooterComponentData {
    /// Format a token count for display.
    pub fn format_token_count(count: u64) -> String {
        if count < 1000 {
            count.to_string()
        } else if count < 10000 {
            format!("{:.1}k", count as f64 / 1000.0)
        } else if count < 1_000_000 {
            format!("{}k", count / 1000)
        } else if count < 10_000_000 {
            format!("{:.1}M", count as f64 / 1_000_000.0)
        } else {
            format!("{}M", count / 1_000_000)
        }
    }

    /// Render the footer as lines.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Line 1: pwd (git branch) • session_name
        let mut pwd_parts = Vec::new();
        if let Some(ref pwd) = self.pwd {
            pwd_parts.push(pwd.clone());
        }
        if let Some(ref branch) = self.git_branch {
            pwd_parts.push(format!("({})", branch));
        }
        if let Some(ref session) = self.session_name {
            pwd_parts.push(format!("• {}", session));
        }
        let pwd_line = pwd_parts.join(" ");
        lines.push(ansi::dim(&truncate_str(&pwd_line, width.saturating_sub(3))));

        // Line 2: stats left | model right
        let mut stats_parts = Vec::new();
        if self.input_tokens > 0 {
            stats_parts.push(format!("↑{}", Self::format_token_count(self.input_tokens)));
        }
        if self.output_tokens > 0 {
            stats_parts.push(format!("↓{}", Self::format_token_count(self.output_tokens)));
        }
        if self.cache_read_tokens > 0 {
            stats_parts.push(format!(
                "R{}",
                Self::format_token_count(self.cache_read_tokens)
            ));
        }
        if self.cache_write_tokens > 0 {
            stats_parts.push(format!(
                "W{}",
                Self::format_token_count(self.cache_write_tokens)
            ));
        }

        // Cost
        if self.total_cost > 0.0 || self.using_subscription {
            let cost_str = format!(
                "${:.3}{}",
                self.total_cost,
                if self.using_subscription {
                    " (sub)"
                } else {
                    ""
                }
            );
            stats_parts.push(cost_str);
        }

        // Context window with color coding
        let auto_indicator = if self.auto_compact_enabled {
            " (auto)"
        } else {
            ""
        };
        let ctx_display = if self.context_window > 0 {
            format!(
                "{:.1}%/{}{}",
                self.context_window_pct,
                Self::format_token_count(self.context_window),
                auto_indicator
            )
        } else {
            format!("{:.1}%{}", self.context_window_pct, auto_indicator)
        };
        let ctx_colored = if self.context_window_pct > 90.0 {
            ansi::error(&ctx_display)
        } else if self.context_window_pct > 70.0 {
            ansi::warning(&ctx_display)
        } else {
            ctx_display
        };
        if self.context_window_pct > 0.0 {
            stats_parts.push(ctx_colored);
        }

        let stats_left = stats_parts.join(" ");

        // Right side: model name + thinking level
        let model_name = if self.model_name.is_empty() {
            "no-model"
        } else {
            &self.model_name
        };
        let mut right_side = model_name.to_string();
        if self.thinking_level != "off" {
            right_side = format!("{} • {}", model_name, self.thinking_level);
        }

        // Prepend provider if multiple providers
        if self.available_provider_count > 1 && !self.provider_name.is_empty() {
            let with_provider = format!("({}) {}", self.provider_name, right_side);
            let with_provider_len = visible_len(&with_provider);
            let stats_left_len = visible_len(&stats_left);
            if stats_left_len + 2 + with_provider_len <= width {
                right_side = with_provider;
            }
        }

        // Combine stats and model info with padding
        let stats_left_len = visible_len(&stats_left);
        let right_side_len = visible_len(&right_side);
        let min_padding = 2;

        let stats_line = if stats_left_len + min_padding + right_side_len <= width {
            let padding = width - stats_left_len - right_side_len;
            format!(
                "{}{}{}",
                ansi::dim(&stats_left),
                " ".repeat(padding),
                ansi::dim(&right_side)
            )
        } else if stats_left_len < width {
            let available = width - stats_left_len - min_padding;
            let truncated_right = truncate_str(&right_side, available);
            let truncated_len = visible_len(&truncated_right);
            let padding = width.saturating_sub(stats_left_len + truncated_len);
            format!(
                "{}{}{}",
                ansi::dim(&stats_left),
                " ".repeat(padding),
                ansi::dim(&truncated_right)
            )
        } else {
            ansi::dim(&stats_left)
        };

        lines.push(stats_line);

        // Line 3: extension statuses
        if !self.extension_statuses.is_empty() {
            let mut sorted = self.extension_statuses.clone();
            sorted.sort_by(|a, b| a.0.cmp(&b.0));
            let status_line: String = sorted
                .iter()
                .map(|(_, text)| {
                    // Sanitize: replace newlines/tabs with space, collapse spaces
                    text.chars()
                        .map(|c| {
                            if c == '\n' || c == '\r' || c == '\t' {
                                ' '
                            } else {
                                c
                            }
                        })
                        .collect::<String>()
                        .split_whitespace()
                        .collect::<Vec<_>>()
                        .join(" ")
                })
                .collect::<Vec<_>>()
                .join(" ");
            lines.push(truncate_str(&status_line, width.saturating_sub(3)));
        }

        lines
    }
}

/// Calculate the visible width of a string (ignoring ANSI escape codes).
fn visible_len(s: &str) -> usize {
    let mut len = 0usize;
    let chars: Vec<char> = s.chars().collect();
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == '\x1b' && i + 1 < chars.len() && chars[i + 1] == '[' {
            // Skip ANSI escape sequence
            i += 2;
            while i < chars.len() {
                if chars[i].is_ascii_alphabetic() {
                    i += 1;
                    break;
                }
                // Handle intermediate parameters like ; and digits
                i += 1;
            }
        } else if chars[i] == '\x1b' && i + 1 < chars.len() && chars[i + 1] == ']' {
            // Skip OSC sequence (like \x1b]133;A\x07)
            i += 2;
            while i < chars.len() {
                if chars[i] == '\x07' || chars[i] == '\x1b' {
                    i += 1;
                    break;
                }
                i += 1;
            }
        } else {
            len += if is_wide_char(chars[i]) { 2 } else { 1 };
            i += 1;
        }
    }
    len
}

// ─────────────────────────────────────────────────────────────────────────────
// 7. VisualTruncate
// ─────────────────────────────────────────────────────────────────────────────

/// Result of truncating text to visual lines.
#[derive(Debug, Clone)]
pub struct VisualTruncateResult {
    /// The visual lines to display.
    pub visual_lines: Vec<String>,
    /// Number of visual lines that were skipped (hidden).
    pub skipped_count: usize,
}

/// Truncates text to a maximum number of visual lines from the end.
/// Accounts for line wrapping based on terminal width.
pub struct VisualTruncate;

impl VisualTruncate {
    /// Truncate text to max visual lines from the end.
    pub fn truncate(
        text: &str,
        max_visual_lines: usize,
        width: usize,
        padding_x: usize,
    ) -> VisualTruncateResult {
        if text.is_empty() {
            return VisualTruncateResult {
                visual_lines: Vec::new(),
                skipped_count: 0,
            };
        }

        // Split into logical lines, then wrap each to width
        let effective_width = width.saturating_sub(padding_x).max(1);
        let mut all_visual_lines = Vec::new();

        for logical_line in text.lines() {
            if logical_line.is_empty() {
                all_visual_lines.push(String::new());
            } else {
                // Wrap line to effective_width
                let mut current = String::new();
                let mut current_width = 0;

                for c in logical_line.chars() {
                    let cw = if is_wide_char(c) { 2 } else { 1 };
                    if current_width + cw > effective_width {
                        all_visual_lines.push(std::mem::take(&mut current));
                        current_width = 0;
                    }
                    current.push(c);
                    current_width += cw;
                }
                if !current.is_empty() {
                    all_visual_lines.push(current);
                }
            }
        }

        if all_visual_lines.len() <= max_visual_lines {
            return VisualTruncateResult {
                visual_lines: all_visual_lines,
                skipped_count: 0,
            };
        }

        let truncated_lines: Vec<String> =
            all_visual_lines.split_off(all_visual_lines.len() - max_visual_lines);
        let skipped_count = all_visual_lines.len();

        VisualTruncateResult {
            visual_lines: truncated_lines,
            skipped_count,
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 8. ShowImagesSelector
// ─────────────────────────────────────────────────────────────────────────────

/// State for toggling image display.
#[derive(Debug, Clone)]
pub struct ShowImagesSelector {
    /// Current value (true = show images, false = hide).
    pub current_value: bool,
    /// Available options.
    pub options: Vec<ShowImagesOption>,
    /// Selected index.
    pub selected_index: usize,
}

/// An option in the show images selector.
#[derive(Debug, Clone)]
pub struct ShowImagesOption {
    /// Value to set ("yes" or "no").
    pub value: String,
    /// Display label.
    pub label: String,
    /// Description.
    pub description: String,
}

impl ShowImagesSelector {
    pub fn new(current_value: bool) -> Self {
        Self {
            current_value,
            options: vec![
                ShowImagesOption {
                    value: "yes".to_string(),
                    label: "Yes".to_string(),
                    description: "Show images inline in terminal".to_string(),
                },
                ShowImagesOption {
                    value: "no".to_string(),
                    label: "No".to_string(),
                    description: "Show text placeholder instead".to_string(),
                },
            ],
            selected_index: if current_value { 0 } else { 1 },
        }
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        if self.selected_index < self.options.len() - 1 {
            self.selected_index += 1;
        }
    }

    /// Get the selected option.
    pub fn selected(&self) -> &ShowImagesOption {
        &self.options[self.selected_index]
    }

    /// Confirm selection, returns true if show images.
    pub fn confirm(&self) -> bool {
        self.options[self.selected_index].value == "yes"
    }

    /// Render the selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Top border
        lines.push("─".repeat(width.min(60)));

        // Options
        for (i, option) in self.options.iter().enumerate() {
            let marker = if i == self.selected_index {
                ansi::accent("›")
            } else {
                " ".to_string()
            };
            let label = if i == self.selected_index {
                ansi::bold(&option.label)
            } else {
                option.label.clone()
            };
            let desc = ansi::muted(&option.description);
            lines.push(format!(" {} {}  {}", marker, label, desc));
        }

        // Bottom border
        lines.push("─".repeat(width.min(60)));

        lines
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// 9. CountdownTimer
// ─────────────────────────────────────────────────────────────────────────────

/// Countdown timer for timeouts, renders as a display string.
#[derive(Debug, Clone)]
pub struct CountdownTimer {
    /// Total seconds remaining.
    pub remaining_seconds: u64,
    /// Total seconds the timer was initialized with.
    pub total_seconds: u64,
}

impl CountdownTimer {
    /// Create a new countdown timer.
    pub fn new(timeout_seconds: u64) -> Self {
        Self {
            remaining_seconds: timeout_seconds,
            total_seconds: timeout_seconds,
        }
    }

    /// Create from milliseconds.
    pub fn from_millis(timeout_ms: u64) -> Self {
        Self::new((timeout_ms + 999) / 1000)
    }

    /// Tick the timer down by one second.
    /// Returns true if the timer has expired.
    pub fn tick(&mut self) -> bool {
        if self.remaining_seconds > 0 {
            self.remaining_seconds -= 1;
        }
        self.remaining_seconds == 0
    }

    /// Check if the timer has expired.
    pub fn is_expired(&self) -> bool {
        self.remaining_seconds == 0
    }

    /// Render the countdown display.
    pub fn render(&self) -> String {
        if self.remaining_seconds == 0 {
            ansi::error("⏰ Expired").to_string()
        } else if self.remaining_seconds <= 5 {
            ansi::warning(&format!("⏳ {}s remaining", self.remaining_seconds))
        } else {
            ansi::dim(&format!("⏳ {}s remaining", self.remaining_seconds))
        }
    }

    /// Render as a progress bar with the countdown.
    pub fn render_bar(&self, width: usize) -> String {
        if self.total_seconds == 0 {
            return String::new();
        }
        let fraction = self.remaining_seconds as f64 / self.total_seconds as f64;
        let filled = ((width as f64) * fraction) as usize;
        let empty = width - filled;

        let bar = if self.remaining_seconds <= 5 {
            format!("{}{}", "█".repeat(filled), "░".repeat(empty))
        } else {
            format!("{}{}", "█".repeat(filled), "░".repeat(empty))
        };

        format!("{} {}", ansi::dim(&bar), self.render())
    }
}

// ── PKCE Helper Functions ────────────────────────────────────────────────────

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use sha2::{Digest, Sha256};

/// Generate a cryptographically-random code_verifier (43 chars, RFC 7636 §4.1).
pub fn generate_code_verifier() -> String {
    let mut bytes = [0u8; 32]; // 32 bytes → 43 base64url chars
    rand::thread_rng().fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

/// Derive the code_challenge from a code_verifier using S256 (SHA-256 + base64url).
pub fn derive_code_challenge(verifier: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(verifier.as_bytes());
    let hash = hasher.finalize();
    URL_SAFE_NO_PAD.encode(hash)
}

/// Synchronous version of derive_code_challenge for use in non-async contexts
fn derive_code_challenge_sync(verifier: &str) -> String {
    derive_code_challenge(verifier)
}

/// Generate an opaque state parameter (22 random base64url chars).
fn generate_state_token() -> String {
    let mut bytes = [0u8; 16];
    rand::thread_rng().fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

// ── Selector Components ─────────────────────────────────────────────────────
//
// Interactive mode selector components ported from pi-mono.
// These are rendering helper structs that produce Vec<String> lines for terminal display.
// Each selector supports fuzzy search filtering and keyboard navigation.

/// Supported thinking levels for reasoning-capable models.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ThinkingLevel {
    #[default]
    Off,
    Minimal,
    Low,
    Medium,
    High,
    XHigh,
}

impl ThinkingLevel {
    pub fn as_str(&self) -> &'static str {
        match self {
            ThinkingLevel::Off => "off",
            ThinkingLevel::Minimal => "minimal",
            ThinkingLevel::Low => "low",
            ThinkingLevel::Medium => "medium",
            ThinkingLevel::High => "high",
            ThinkingLevel::XHigh => "xhigh",
        }
    }

    pub fn description(&self) -> &'static str {
        match self {
            ThinkingLevel::Off => "No reasoning",
            ThinkingLevel::Minimal => "Very brief reasoning (~1k tokens)",
            ThinkingLevel::Low => "Light reasoning (~2k tokens)",
            ThinkingLevel::Medium => "Moderate reasoning (~8k tokens)",
            ThinkingLevel::High => "Deep reasoning (~16k tokens)",
            ThinkingLevel::XHigh => "Maximum reasoning (~32k tokens)",
        }
    }

    pub fn all() -> Vec<ThinkingLevel> {
        vec![
            ThinkingLevel::Off,
            ThinkingLevel::Minimal,
            ThinkingLevel::Low,
            ThinkingLevel::Medium,
            ThinkingLevel::High,
            ThinkingLevel::XHigh,
        ]
    }

    /// Get the rank of this thinking level (0=Off, 5=XHigh).
    /// Used for model-specific level clamping.
    pub fn rank(&self) -> usize {
        match self {
            ThinkingLevel::Off => 0,
            ThinkingLevel::Minimal => 1,
            ThinkingLevel::Low => 2,
            ThinkingLevel::Medium => 3,
            ThinkingLevel::High => 4,
            ThinkingLevel::XHigh => 5,
        }
    }

    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "off" => Some(ThinkingLevel::Off),
            "minimal" => Some(ThinkingLevel::Minimal),
            "low" => Some(ThinkingLevel::Low),
            "medium" => Some(ThinkingLevel::Medium),
            "high" => Some(ThinkingLevel::High),
            "xhigh" => Some(ThinkingLevel::XHigh),
            _ => None,
        }
    }
}

impl std::fmt::Display for ThinkingLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

// ── Simple fuzzy matching for selector components ─────────────────────────────

/// Simple fuzzy score: returns a match quality score (higher = better), or None if no match.
/// Matches characters in order, allowing gaps.
pub fn fuzzy_score(query: &str, text: &str) -> Option<usize> {
    let query_lower = query.to_lowercase();
    let text_lower = text.to_lowercase();

    // Quick substring check - highest priority
    if text_lower.contains(&query_lower) {
        return Some(1000 + query.len());
    }

    // Fuzzy character matching
    let mut query_chars = query_lower.chars().peekable();
    let mut score = 0;
    let mut last_match_pos = 0;

    for (i, tc) in text_lower.char_indices() {
        if let Some(&qc) = query_chars.peek() {
            if tc == qc {
                // Bonus for matching at word boundaries
                if i == 0
                    || text.as_bytes().get(i - 1) == Some(&b' ')
                    || text.as_bytes().get(i - 1) == Some(&b'/')
                {
                    score += 10;
                } else if i == last_match_pos + 1 {
                    // Bonus for consecutive matches
                    score += 5;
                } else {
                    score += 1;
                }
                last_match_pos = i;
                query_chars.next();
            }
        }
    }

    if query_chars.peek().is_none() {
        Some(score)
    } else {
        None
    }
}

/// Filter a list of items by fuzzy matching against a query.
/// Returns indices of matching items sorted by match quality (best first).
pub fn fuzzy_filter_indices(items: &[impl AsRef<str>], query: &str) -> Vec<usize> {
    if query.is_empty() {
        return (0..items.len()).collect();
    }
    let mut scored: Vec<(usize, usize)> = items
        .iter()
        .enumerate()
        .filter_map(|(i, item)| fuzzy_score(query, item.as_ref()).map(|score| (i, score)))
        .collect();
    scored.sort_by(|a, b| b.1.cmp(&a.1));
    scored.into_iter().map(|(i, _)| i).collect()
}

// ── 0. SessionSelectorSearch ─────────────────────────────────────────────────

/// A parsed search query with token extraction.
///
/// Supports:
/// - Plain tokens (fuzzy matched)
/// - Quoted phrases (exact substring matched)
/// - Regex mode: `re:<pattern>`
#[derive(Debug, Clone)]
pub struct ParsedSearchQuery {
    /// Query mode: tokens or regex.
    pub mode: SearchQueryMode,
    /// Extracted tokens.
    pub tokens: Vec<SearchToken>,
    /// Compiled regex (only in regex mode).
    pub regex: Option<regex::Regex>,
    /// Error message if parsing failed.
    pub error: Option<String>,
}

/// Search query mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchQueryMode {
    Tokens,
    Regex,
}

/// A single search token.
#[derive(Debug, Clone)]
pub struct SearchToken {
    /// Token kind: fuzzy or phrase.
    pub kind: SearchTokenKind,
    /// Token value.
    pub value: String,
}

/// Search token kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchTokenKind {
    Fuzzy,
    Phrase,
}

/// Result of matching a session against a parsed query.
#[derive(Debug, Clone)]
pub struct SessionMatchResult {
    /// Whether the session matches.
    pub matches: bool,
    /// Match score (lower is better).
    pub score: usize,
}

/// Parse a search query string into tokens.
///
/// Supports:
/// - `re:<pattern>` for regex mode
/// - Quoted phrases: `"exact phrase"`
/// - Plain words (fuzzy matched)
/// - Mixed: `foo "node cve" bar`
pub fn parse_search_query(query: &str) -> ParsedSearchQuery {
    let trimmed = query.trim();
    if trimmed.is_empty() {
        return ParsedSearchQuery {
            mode: SearchQueryMode::Tokens,
            tokens: Vec::new(),
            regex: None,
            error: None,
        };
    }

    // Regex mode: re:<pattern>
    if let Some(pattern) = trimmed.strip_prefix("re:") {
        let pattern = pattern.trim();
        if pattern.is_empty() {
            return ParsedSearchQuery {
                mode: SearchQueryMode::Regex,
                tokens: Vec::new(),
                regex: None,
                error: Some("Empty regex".to_string()),
            };
        }
        match regex::Regex::new(&format!("(?i){}", pattern)) {
            Ok(re) => ParsedSearchQuery {
                mode: SearchQueryMode::Regex,
                tokens: Vec::new(),
                regex: Some(re),
                error: None,
            },
            Err(e) => ParsedSearchQuery {
                mode: SearchQueryMode::Regex,
                tokens: Vec::new(),
                regex: None,
                error: Some(e.to_string()),
            },
        }
    } else {
        // Token mode with quote support
        let mut tokens = Vec::new();
        let mut buf = String::new();
        let mut in_quote = false;
        let mut had_unclosed_quote = false;

        let flush = |buf: &mut String, kind: SearchTokenKind, tokens: &mut Vec<SearchToken>| {
            let v = buf.trim().to_string();
            buf.clear();
            if !v.is_empty() {
                tokens.push(SearchToken { kind, value: v });
            }
        };

        for ch in trimmed.chars() {
            if ch == '"' {
                if in_quote {
                    flush(&mut buf, SearchTokenKind::Phrase, &mut tokens);
                    in_quote = false;
                } else {
                    flush(&mut buf, SearchTokenKind::Fuzzy, &mut tokens);
                    in_quote = true;
                }
                continue;
            }

            if !in_quote && ch.is_whitespace() {
                flush(&mut buf, SearchTokenKind::Fuzzy, &mut tokens);
                continue;
            }

            buf.push(ch);
        }

        if in_quote {
            had_unclosed_quote = true;
        }

        // If quotes were unbalanced, fall back to plain whitespace tokenization
        if had_unclosed_quote {
            tokens = trimmed
                .split_whitespace()
                .filter(|t| !t.is_empty())
                .map(|t| SearchToken {
                    kind: SearchTokenKind::Fuzzy,
                    value: t.to_string(),
                })
                .collect();
        } else {
            flush(&mut buf, if in_quote { SearchTokenKind::Phrase } else { SearchTokenKind::Fuzzy }, &mut tokens);
        }

        ParsedSearchQuery {
            mode: SearchQueryMode::Tokens,
            tokens,
            regex: None,
            error: None,
        }
    }
}

/// Match an enhanced session info against a parsed query.
///
/// The session's ID, name, label, working directory, and model are used as search text.
pub fn match_session(session: &EnhancedSessionInfo, parsed: &ParsedSearchQuery) -> SessionMatchResult {
    let text = format!(
        "{} {} {} {}",
        session.id,
        session.name,
        session.label.as_deref().unwrap_or(""),
        session.working_dir.as_deref().unwrap_or("")
    );

    if parsed.mode == SearchQueryMode::Regex {
        if let Some(ref re) = parsed.regex {
            if let Some(mat) = re.find(&text) {
                return SessionMatchResult {
                    matches: true,
                    score: mat.start(),
                };
            }
        }
        return SessionMatchResult {
            matches: false,
            score: 0,
        };
    }

    if parsed.tokens.is_empty() {
        return SessionMatchResult {
            matches: true,
            score: 0,
        };
    }

    let mut total_score = 0usize;
    let text_lower = text.to_lowercase().replace(char::is_whitespace, " ");

    for token in &parsed.tokens {
        if token.kind == SearchTokenKind::Phrase {
            let phrase = token.value.to_lowercase().replace(char::is_whitespace, " ");
            if phrase.is_empty() {
                continue;
            }
            if let Some(idx) = text_lower.find(&phrase) {
                total_score += idx;
            } else {
                return SessionMatchResult {
                    matches: false,
                    score: 0,
                };
            }
        } else {
            // Fuzzy match
            match fuzzy_score(&token.value, &text) {
                Some(score) => {
                    // Invert: fuzzy_score returns higher=better, we want lower=better
                    total_score += 10000 - score.min(9999);
                }
                None => {
                    return SessionMatchResult {
                        matches: false,
                        score: 0,
                    };
                }
            }
        }
    }

    SessionMatchResult {
        matches: true,
        score: total_score,
    }
}

/// Filter and sort sessions using a parsed query.
///
/// Supports three sort modes:
/// - `Recent`: Keep input order (filter only)
/// - `Fuzzy` / `Threaded`: Sort by match score, tie-break by modified date
pub fn filter_and_sort_sessions(
    sessions: &[EnhancedSessionInfo],
    query: &str,
    sort_mode: SessionSortMode,
    name_filter: SessionNameFilter,
) -> Vec<EnhancedSessionInfo> {
    let name_filtered: Vec<_> = if name_filter == SessionNameFilter::Named {
        sessions.iter().filter(|s| !s.name.is_empty()).cloned().collect()
    } else {
        sessions.to_vec()
    };

    let trimmed = query.trim();
    if trimmed.is_empty() {
        return name_filtered;
    }

    let parsed = parse_search_query(query);
    if parsed.error.is_some() {
        return Vec::new();
    }

    if sort_mode == SessionSortMode::Recent {
        return name_filtered
            .into_iter()
            .filter(|s| match_session(s, &parsed).matches)
            .collect();
    }

    // Relevance mode: sort by score
    let mut scored: Vec<(EnhancedSessionInfo, usize)> = name_filtered
        .into_iter()
        .filter_map(|s| {
            let result = match_session(&s, &parsed);
            if result.matches {
                Some((s, result.score))
            } else {
                None
            }
        })
        .collect();

    scored.sort_by(|a, b| {
        a.1.cmp(&b.1).then_with(|| {
            // Tie-break by updated_at descending
            b.0.updated_at.cmp(&a.0.updated_at)
        })
    });

    scored.into_iter().map(|(s, _)| s).collect()
}

/// Session selector with advanced search (parsed query, fuzzy + phrase + regex).
///
/// This is the search/filter component from `session-selector-search.ts`, providing:
/// - Token-based search with quoted phrase support
/// - Regex mode via `re:<pattern>`
/// - Fuzzy matching on session names and IDs
/// - Sort by relevance or recency
/// - Name filtering (all / named-only)
#[derive(Debug, Clone)]
pub struct SessionSelectorSearch {
    /// All sessions.
    pub sessions: Vec<EnhancedSessionInfo>,
    /// Filtered sessions after search.
    pub filtered_sessions: Vec<EnhancedSessionInfo>,
    /// Current raw search query.
    pub query: String,
    /// Parsed search query.
    pub parsed_query: ParsedSearchQuery,
    /// Sort mode.
    pub sort_mode: SessionSortMode,
    /// Name filter mode.
    pub name_filter: SessionNameFilter,
    /// Selected index in filtered_sessions.
    pub selected_index: usize,
    /// Scroll offset for rendering.
    pub scroll_offset: usize,
    /// Maximum visible items.
    pub max_visible: usize,
}

impl SessionSelectorSearch {
    /// Create a new session selector search.
    pub fn new(sessions: Vec<EnhancedSessionInfo>) -> Self {
        let filtered_sessions = sessions.clone();
        Self {
            sessions,
            filtered_sessions,
            query: String::new(),
            parsed_query: parse_search_query(""),
            sort_mode: SessionSortMode::Recent,
            name_filter: SessionNameFilter::All,
            selected_index: 0,
            scroll_offset: 0,
            max_visible: 15,
        }
    }

    /// Set the search query and re-filter.
    pub fn set_query(&mut self, query: String) {
        self.query = query;
        self.parsed_query = parse_search_query(&self.query);
        self.apply_filter();
    }

    /// Set the sort mode.
    pub fn set_sort_mode(&mut self, mode: SessionSortMode) {
        self.sort_mode = mode;
        self.apply_filter();
    }

    /// Set the name filter.
    pub fn set_name_filter(&mut self, filter: SessionNameFilter) {
        self.name_filter = filter;
        self.apply_filter();
    }

    /// Toggle name filter.
    pub fn toggle_name_filter(&mut self) {
        self.name_filter = match self.name_filter {
            SessionNameFilter::All => SessionNameFilter::Named,
            SessionNameFilter::Named => SessionNameFilter::All,
        };
        self.apply_filter();
    }

    /// Toggle sort mode.
    pub fn toggle_sort(&mut self) {
        self.sort_mode = match self.sort_mode {
            SessionSortMode::Recent => SessionSortMode::Threaded,
            SessionSortMode::Threaded => SessionSortMode::Fuzzy,
            SessionSortMode::Fuzzy => SessionSortMode::Recent,
        };
        self.apply_filter();
    }

    fn apply_filter(&mut self) {
        self.filtered_sessions = filter_and_sort_sessions(
            &self.sessions,
            &self.query,
            self.sort_mode,
            self.name_filter,
        );
        self.selected_index = self
            .selected_index
            .min(self.filtered_sessions.len().saturating_sub(1));
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.filtered_sessions.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
            self.adjust_scroll();
        }
    }

    fn adjust_scroll(&mut self) {
        if self.selected_index < self.scroll_offset {
            self.scroll_offset = self.selected_index;
        } else if self.selected_index >= self.scroll_offset + self.max_visible {
            self.scroll_offset = self.selected_index - self.max_visible + 1;
        }
    }

    /// Get the currently selected session.
    pub fn selected(&self) -> Option<&EnhancedSessionInfo> {
        self.filtered_sessions.get(self.selected_index)
    }

    /// Get the parse error, if any.
    pub fn parse_error(&self) -> Option<&str> {
        self.parsed_query.error.as_deref()
    }

    /// Render the session selector search as a vector of lines.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Header
        lines.push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push("\x1b[1mSearch Sessions\x1b[0m".to_string());

        // Query display with token highlighting
        if !self.query.is_empty() {
            if let Some(ref err) = self.parsed_query.error {
                lines.push(format!("\x1b[31mQuery error: {}\x1b[0m", err));
            } else {
                let mode_label = match self.parsed_query.mode {
                    SearchQueryMode::Tokens => "tokens",
                    SearchQueryMode::Regex => "regex",
                };
                let token_count = self.parsed_query.tokens.len();
                lines.push(format!(
                    "\x1b[2m[{}]\x1b[0m {}",
                    mode_label,
                    if token_count > 0 {
                        self.parsed_query
                            .tokens
                            .iter()
                            .map(|t| match t.kind {
                                SearchTokenKind::Fuzzy => format!("{}", t.value),
                                SearchTokenKind::Phrase => format!("\"\x1b[33m{}\x1b[0m\"", t.value),
                            })
                            .collect::<Vec<_>>()
                            .join(" ")
                    } else {
                        self.query.clone()
                    }
                ));
            }
        }

        // Sort and filter indicators
        lines.push(format!(
            "\x1b[2mSort: \x1b[36m{}\x1b[0m \x1b[2mFilter: \x1b[36m{}\x1b[0m",
            self.sort_mode.label(),
            match self.name_filter {
                SessionNameFilter::All => "All",
                SessionNameFilter::Named => "Named",
            }
        ));

        lines.push(String::new());

        if self.filtered_sessions.is_empty() {
            lines.push("\x1b[2m  No matching sessions\x1b[0m".to_string());
            lines.push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
            return lines;
        }

        // Render visible sessions
        let visible: Vec<_> = self
            .filtered_sessions
            .iter()
            .skip(self.scroll_offset)
            .take(self.max_visible)
            .collect();

        for (i, session) in visible.iter().enumerate() {
            let real_idx = self.scroll_offset + i;
            let is_selected = real_idx == self.selected_index;

            let marker = if is_selected {
                "\x1b[36m▶\x1b[0m"
            } else {
                " "
            };

            let branch = if session.parent_id.is_some() {
                "├─ "
            } else {
                "  "
            };

            let name = if session.name.is_empty() {
                &session.id[..8.min(session.id.len())]
            } else {
                &session.name
            };

            let label = session
                .label
                .as_ref()
                .map(|l| format!(" \x1b[33m[{}]\x1b[0m", l))
                .unwrap_or_default();

            let time = format_relative_time(
                session.updated_at.as_ref().unwrap_or(&session.created_at),
            );

            let line = format!(
                "{} {}{:<30} {} msg:{}",
                marker,
                branch,
                name,
                time,
                session.message_count,
            );
            let mut line = truncate_str(&line, width);
            line.push_str(&label);
            lines.push(line);
        }

        // Scroll indicator
        if self.filtered_sessions.len() > self.max_visible {
            lines.push(format!(
                "\x1b[2m  ({}/{})\x1b[0m",
                self.selected_index + 1,
                self.filtered_sessions.len()
            ));
        }

        lines.push(String::new());
        lines.push(
            "\x1b[2mType to search · \"quotes\" for phrases · re: for regex\x1b[0m".to_string(),
        );
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());

        lines
    }
}

// ── 1. ConfigSelector ─────────────────────────────────────────────────────────

/// Resource type for configuration items.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceType {
    Extensions,
    Skills,
    Prompts,
    Themes,
}

impl ResourceType {
    pub fn label(&self) -> &'static str {
        match self {
            ResourceType::Extensions => "Extensions",
            ResourceType::Skills => "Skills",
            ResourceType::Prompts => "Prompts",
            ResourceType::Themes => "Themes",
        }
    }
}

/// A single configuration resource item.
#[derive(Debug, Clone)]
pub struct ConfigResourceItem {
    pub path: String,
    pub enabled: bool,
    pub resource_type: ResourceType,
    pub display_name: String,
    pub group_key: String,
}

/// A group of configuration resources.
#[derive(Debug, Clone)]
pub struct ConfigResourceGroup {
    pub key: String,
    pub label: String,
    pub subgroups: Vec<ConfigResourceSubgroup>,
}

/// A subgroup of configuration resources (e.g., all extensions in a package).
#[derive(Debug, Clone)]
pub struct ConfigResourceSubgroup {
    pub resource_type: ResourceType,
    pub label: String,
    pub items: Vec<ConfigResourceItem>,
}

/// Flat entry for display in the config selector.
#[derive(Debug, Clone)]
pub enum ConfigFlatEntry {
    Group {
        group: ConfigResourceGroup,
    },
    Subgroup {
        subgroup: ConfigResourceSubgroup,
        group_label: String,
    },
    Item {
        item: ConfigResourceItem,
        group_label: String,
        subgroup_label: String,
    },
}

/// Config file selection with fuzzy search.
/// Renders a hierarchical list of configuration resources (extensions, skills, prompts, themes)
/// organized by source (package, user, project) with enable/disable toggle support.
#[derive(Debug, Clone)]
pub struct ConfigSelector {
    /// All flat entries (groups, subgroups, items).
    pub flat_entries: Vec<ConfigFlatEntry>,
    /// Filtered entries based on search.
    pub filtered_entries: Vec<ConfigFlatEntry>,
    /// Current search query.
    pub query: String,
    /// Selected index in filtered_entries.
    pub selected_index: usize,
    /// Maximum visible items.
    pub max_visible: usize,
}

impl ConfigSelector {
    /// Create a new config selector from resource groups.
    pub fn new(groups: Vec<ConfigResourceGroup>) -> Self {
        let flat_entries = Self::build_flat_list(&groups);
        let filtered_entries = flat_entries.clone();
        let selected_index = flat_entries
            .iter()
            .position(|e| matches!(e, ConfigFlatEntry::Item { .. }))
            .unwrap_or(0);
        Self {
            flat_entries,
            filtered_entries,
            query: String::new(),
            selected_index,
            max_visible: 15,
        }
    }

    fn build_flat_list(groups: &[ConfigResourceGroup]) -> Vec<ConfigFlatEntry> {
        let mut entries = Vec::new();
        for group in groups {
            entries.push(ConfigFlatEntry::Group {
                group: group.clone(),
            });
            for subgroup in &group.subgroups {
                entries.push(ConfigFlatEntry::Subgroup {
                    subgroup: subgroup.clone(),
                    group_label: group.label.clone(),
                });
                for item in &subgroup.items {
                    entries.push(ConfigFlatEntry::Item {
                        item: item.clone(),
                        group_label: group.label.clone(),
                        subgroup_label: subgroup.label.clone(),
                    });
                }
            }
        }
        entries
    }

    /// Apply search filter.
    pub fn set_filter(&mut self, query: String) {
        self.query = query;
        self.apply_filter();
    }

    fn apply_filter(&mut self) {
        if self.query.trim().is_empty() {
            self.filtered_entries = self.flat_entries.clone();
            self.select_first_item();
            return;
        }

        let lower_query = self.query.to_lowercase();

        // Find matching items
        let mut matching_groups = std::collections::HashSet::new();
        let mut matching_subgroups = std::collections::HashSet::new();
        let mut matching_items = std::collections::HashSet::new();

        for (idx, entry) in self.flat_entries.iter().enumerate() {
            if let ConfigFlatEntry::Item { item, .. } = entry {
                if item.display_name.to_lowercase().contains(&lower_query)
                    || item.path.to_lowercase().contains(&lower_query)
                    || item
                        .resource_type
                        .label()
                        .to_lowercase()
                        .contains(&lower_query)
                {
                    matching_items.insert(idx);
                }
            }
        }

        // Include parent groups/subgroups of matching items
        let mut current_group_idx: Option<usize> = None;
        let mut current_subgroup_idx: Option<usize> = None;
        for (idx, entry) in self.flat_entries.iter().enumerate() {
            match entry {
                ConfigFlatEntry::Group { .. } => current_group_idx = Some(idx),
                ConfigFlatEntry::Subgroup { .. } => current_subgroup_idx = Some(idx),
                ConfigFlatEntry::Item { .. } => {
                    if matching_items.contains(&idx) {
                        if let Some(gi) = current_group_idx {
                            matching_groups.insert(gi);
                        }
                        if let Some(si) = current_subgroup_idx {
                            matching_subgroups.insert(si);
                        }
                    }
                }
            }
        }

        self.filtered_entries = self
            .flat_entries
            .iter()
            .enumerate()
            .filter(|(idx, _)| {
                matching_groups.contains(idx)
                    || matching_subgroups.contains(idx)
                    || matching_items.contains(idx)
            })
            .map(|(_, e)| e.clone())
            .collect();

        self.select_first_item();
    }

    fn select_first_item(&mut self) {
        self.selected_index = self
            .filtered_entries
            .iter()
            .position(|e| matches!(e, ConfigFlatEntry::Item { .. }))
            .unwrap_or(0);
    }

    /// Move selection to the next item (skipping headers).
    pub fn move_up(&mut self) {
        self.selected_index = self.find_next_item(self.selected_index, -1);
    }

    /// Move selection to the previous item (skipping headers).
    pub fn move_down(&mut self) {
        self.selected_index = self.find_next_item(self.selected_index, 1);
    }

    fn find_next_item(&self, from: usize, direction: i32) -> usize {
        let mut idx = from as i32 + direction;
        while idx >= 0 && (idx as usize) < self.filtered_entries.len() {
            if matches!(
                self.filtered_entries[idx as usize],
                ConfigFlatEntry::Item { .. }
            ) {
                return idx as usize;
            }
            idx += direction;
        }
        from
    }

    /// Toggle the enabled state of the currently selected item.
    pub fn toggle_selected(&mut self) -> Option<&ConfigResourceItem> {
        if let Some(ConfigFlatEntry::Item { item, .. }) =
            self.filtered_entries.get(self.selected_index)
        {
            let new_enabled = !item.enabled;
            let path = item.path.clone();
            let resource_type = item.resource_type;
            // Update in filtered entries
            for entry in &mut self.filtered_entries {
                if let ConfigFlatEntry::Item { item, .. } = entry {
                    if item.path == path && item.resource_type == resource_type {
                        item.enabled = new_enabled;
                        return Some(item);
                    }
                }
            }
            // Also update in flat entries
            for entry in &mut self.flat_entries {
                if let ConfigFlatEntry::Item { item, .. } = entry {
                    if item.path == path && item.resource_type == resource_type {
                        item.enabled = new_enabled;
                    }
                }
            }
        }
        None
    }

    /// Render the config selector as lines for terminal display.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Header
        lines.push(format!(
            "\x1b[1mResource Configuration\x1b[0m{:>width$}",
            "\x1b[2mSpace=toggle · Esc=close\x1b[0m",
            width = width.saturating_sub(40)
        ));
        lines.push("\x1b[2mType to filter resources\x1b[0m".to_string());

        // Search input
        if !self.query.is_empty() {
            lines.push(format!("> {}", self.query));
        } else {
            lines.push("> ".to_string());
        }
        lines.push(String::new());

        if self.filtered_entries.is_empty() {
            lines.push("\x1b[2m  No resources found\x1b[0m".to_string());
            return lines;
        }

        // Calculate visible range centered on selection
        let total = self.filtered_entries.len();
        let start = if total <= self.max_visible {
            0
        } else {
            (self.selected_index as i32 - self.max_visible as i32 / 2)
                .max(0)
                .min((total - self.max_visible) as i32) as usize
        };
        let end = (start + self.max_visible).min(total);

        for i in start..end {
            let entry = &self.filtered_entries[i];
            let is_selected = i == self.selected_index;

            match entry {
                ConfigFlatEntry::Group { group } => {
                    let line = truncate_str(&group.label, width.saturating_sub(2));
                    lines.push(format!("  \x1b[1m\x1b[36m{}\x1b[0m", line));
                }
                ConfigFlatEntry::Subgroup { subgroup, .. } => {
                    let line = truncate_str(&subgroup.label, width.saturating_sub(4));
                    lines.push(format!("    \x1b[2m{}\x1b[0m", line));
                }
                ConfigFlatEntry::Item { item, .. } => {
                    let cursor = if is_selected { "> " } else { "  " };
                    let checkbox = if item.enabled {
                        "\x1b[32m[x]\x1b[0m"
                    } else {
                        "\x1b[2m[ ]\x1b[0m"
                    };
                    let name = if is_selected {
                        format!("\x1b[1m{}\x1b[0m", item.display_name)
                    } else {
                        item.display_name.clone()
                    };
                    let content = format!("{}    {} {}", cursor, checkbox, name);
                    lines.push(truncate_str(&content, width));
                }
            }
        }

        // Scroll indicator
        if start > 0 || end < total {
            let item_count = self
                .filtered_entries
                .iter()
                .filter(|e| matches!(e, ConfigFlatEntry::Item { .. }))
                .count();
            let current_item = self
                .filtered_entries
                .iter()
                .take(self.selected_index)
                .filter(|e| matches!(e, ConfigFlatEntry::Item { .. }))
                .count()
                + 1;
            lines.push(format!("\x1b[2m  ({}/{})\x1b[0m", current_item, item_count));
        }

        lines
    }
}

// ── 2. ModelSelector (Enhanced) ──────────────────────────────────────────────

/// Model scope for filtering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelScope {
    All,
    Scoped,
}

/// Enhanced model selection with provider tabs, thinking levels, and scope switching.
#[derive(Debug, Clone)]
pub struct ModelSelectorEnhanced {
    /// All available models.
    pub all_models: Vec<ModelInfo>,
    /// Scoped models (filtered by configured providers).
    pub scoped_models: Vec<ModelInfo>,
    /// Currently active models (based on scope).
    pub active_models: Vec<ModelInfo>,
    /// Filtered models (based on search).
    pub filtered_models: Vec<ModelInfo>,
    /// Current search query.
    pub query: String,
    /// Selected index in filtered_models.
    pub selected_index: usize,
    /// Current scope (all or scoped).
    pub scope: ModelScope,
    /// Currently active model id (for highlighting).
    pub current_model_id: Option<String>,
    /// Maximum visible items.
    pub max_visible: usize,
    /// Error message if model loading failed.
    pub error_message: Option<String>,
}

impl ModelSelectorEnhanced {
    /// Create a new enhanced model selector.
    pub fn new(
        all_models: Vec<ModelInfo>,
        scoped_models: Vec<ModelInfo>,
        current_model_id: Option<String>,
    ) -> Self {
        let scope = if scoped_models.is_empty() {
            ModelScope::All
        } else {
            ModelScope::Scoped
        };
        let active_models = if scope == ModelScope::Scoped {
            scoped_models.clone()
        } else {
            all_models.clone()
        };
        let filtered_models = active_models.clone();

        let mut selector = Self {
            all_models,
            scoped_models,
            active_models,
            filtered_models,
            query: String::new(),
            selected_index: 0,
            scope,
            current_model_id,
            max_visible: 10,
            error_message: None,
        };
        selector.pre_select_current();
        selector
    }

    fn pre_select_current(&mut self) {
        if let Some(ref current_id) = self.current_model_id {
            if let Some(idx) = self
                .filtered_models
                .iter()
                .position(|m| m.id == *current_id)
            {
                self.selected_index = idx;
            }
        }
    }

    /// Toggle scope between all and scoped models.
    pub fn toggle_scope(&mut self) {
        self.scope = match self.scope {
            ModelScope::All => ModelScope::Scoped,
            ModelScope::Scoped => ModelScope::All,
        };
        if self.scoped_models.is_empty() {
            self.scope = ModelScope::All;
        }
        self.active_models = if self.scope == ModelScope::Scoped {
            self.scoped_models.clone()
        } else {
            self.all_models.clone()
        };
        self.apply_filter();
    }

    /// Set search filter.
    pub fn set_filter(&mut self, query: String) {
        self.query = query;
        self.apply_filter();
    }

    fn apply_filter(&mut self) {
        if self.query.trim().is_empty() {
            self.filtered_models = self.active_models.clone();
        } else {
            let indices = fuzzy_filter_indices(
                &self
                    .active_models
                    .iter()
                    .map(|m| format!("{} {} {}/{}", m.id, m.provider, m.provider, m.id))
                    .collect::<Vec<_>>(),
                &self.query,
            );
            self.filtered_models = indices
                .into_iter()
                .map(|i| self.active_models[i].clone())
                .collect();
        }
        self.selected_index = self
            .selected_index
            .min(self.filtered_models.len().saturating_sub(1));
        self.pre_select_current();
    }

    /// Move selection up (wraps to bottom).
    pub fn move_up(&mut self) {
        if self.filtered_models.is_empty() {
            return;
        }
        if self.selected_index == 0 {
            self.selected_index = self.filtered_models.len() - 1;
        } else {
            self.selected_index -= 1;
        }
    }

    /// Move selection down (wraps to top).
    pub fn move_down(&mut self) {
        if self.filtered_models.is_empty() {
            return;
        }
        if self.selected_index == self.filtered_models.len() - 1 {
            self.selected_index = 0;
        } else {
            self.selected_index += 1;
        }
    }

    /// Get the currently selected model.
    pub fn selected(&self) -> Option<&ModelInfo> {
        self.filtered_models.get(self.selected_index)
    }

    /// Render the enhanced model selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Scope header
        let scope_text = match self.scope {
            ModelScope::All => "\x1b[36mall\x1b[0m | \x1b[2mscoped\x1b[0m".to_string(),
            ModelScope::Scoped => "\x1b[2mall\x1b[0m | \x1b[36mscoped\x1b[0m".to_string(),
        };
        lines.push(format!(
            "\x1b[2mScope:\x1b[0m {} \x1b[2m(Tab to toggle)\x1b[0m",
            scope_text
        ));

        // Search input
        lines.push(format!("> {}", self.query));
        lines.push(String::new());

        // Error message
        if let Some(ref err) = self.error_message {
            for err_line in err.lines() {
                lines.push(format!("\x1b[31m{}\x1b[0m", err_line));
            }
            return lines;
        }

        if self.filtered_models.is_empty() {
            lines.push("\x1b[2m  No matching models\x1b[0m".to_string());
            return lines;
        }

        // Calculate visible range
        let total = self.filtered_models.len();
        let half = self.max_visible / 2;
        let start = if self.selected_index >= half {
            (self.selected_index - half).min(total.saturating_sub(self.max_visible))
        } else {
            0
        };
        let end = (start + self.max_visible).min(total);

        for i in start..end {
            let model = &self.filtered_models[i];
            let is_selected = i == self.selected_index;
            let is_current = self
                .current_model_id
                .as_ref()
                .map_or(false, |id| id == &model.id);

            let prefix = if is_selected {
                "\x1b[36m→ \x1b[0m"
            } else {
                "  "
            };
            let model_text = if is_selected {
                format!("\x1b[36m{}\x1b[0m", model.id)
            } else {
                model.id.clone()
            };
            let provider_badge = format!("\x1b[2m[{}]\x1b[0m", model.provider);
            let current_badge = if is_current {
                "\x1b[32m ✓\x1b[0m".to_string()
            } else {
                String::new()
            };

            let line = format!(
                "{}{} {}{}",
                prefix, model_text, provider_badge, current_badge
            );
            lines.push(truncate_str(&line, width));
        }

        // Scroll indicator
        if start > 0 || end < total {
            lines.push(format!(
                "\x1b[2m  ({}/{})\x1b[0m",
                self.selected_index + 1,
                total
            ));
        }

        // Detail line for selected model
        if let Some(selected) = self.selected() {
            lines.push(String::new());
            lines.push(format!("\x1b[2m  Model Name: {}\x1b[0m", selected.name));
        }

        lines
    }
}

// ── 3. SessionSelector (Enhanced) ────────────────────────────────────────────

/// Session scope for filtering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionScope {
    CurrentFolder,
    All,
}

/// Sort mode for sessions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSortMode {
    Recent,
    Fuzzy,
    Threaded,
}

impl SessionSortMode {
    pub fn label(&self) -> &'static str {
        match self {
            SessionSortMode::Recent => "Recent",
            SessionSortMode::Fuzzy => "Fuzzy",
            SessionSortMode::Threaded => "Threaded",
        }
    }
}

/// Name filter mode for sessions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionNameFilter {
    All,
    Named,
}

/// Enhanced session info with additional metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnhancedSessionInfo {
    pub id: String,
    pub name: String,
    pub label: Option<String>,
    pub created_at: String,
    pub updated_at: Option<String>,
    pub message_count: usize,
    pub model: Option<String>,
    pub parent_id: Option<String>,
    pub working_dir: Option<String>,
}

/// Format a relative time from a date string.
fn format_relative_time(date_str: &str) -> String {
    // Try parsing common date formats
    let now = chrono::Utc::now();
    let parsed: Option<chrono::DateTime<chrono::Utc>> =
        chrono::DateTime::parse_from_rfc3339(date_str)
            .map(|dt| dt.to_utc())
            .ok()
            .or_else(|| {
                chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S")
                    .map(|dt| dt.and_utc())
                    .ok()
            })
            .or_else(|| {
                chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
                    .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
                    .ok()
            });

    match parsed {
        Some(dt) => {
            let diff = now.signed_duration_since(dt);
            let mins = diff.num_minutes();
            if mins < 1 {
                return "now".to_string();
            }
            if mins < 60 {
                return format!("{}m", mins);
            }
            let hours = diff.num_hours();
            if hours < 24 {
                return format!("{}h", hours);
            }
            let days = diff.num_days();
            if days < 7 {
                return format!("{}d", days);
            }
            if days < 30 {
                return format!("{}w", days / 7);
            }
            if days < 365 {
                return format!("{}mo", days / 30);
            }
            format!("{}y", days / 365)
        }
        None => date_str.to_string(),
    }
}

/// Shorten a path by replacing home directory with ~.
fn shorten_path(path: &str) -> String {
    if let Some(home) = dirs::home_dir() {
        let home_str = home.to_string_lossy();
        if path.starts_with(home_str.as_ref()) {
            return format!("~{}", &path[home_str.len()..]);
        }
    }
    path.to_string()
}

/// Enhanced session selector with search, labels, timestamps, scope, and sort.
#[derive(Debug, Clone)]
pub struct SessionSelectorEnhanced {
    /// All sessions.
    pub sessions: Vec<EnhancedSessionInfo>,
    /// Filtered sessions based on search.
    pub filtered_sessions: Vec<EnhancedSessionInfo>,
    /// Current search query.
    pub query: String,
    /// Selected index in filtered_sessions.
    pub selected_index: usize,
    /// Current scope.
    pub scope: SessionScope,
    /// Current sort mode.
    pub sort_mode: SessionSortMode,
    /// Name filter mode.
    pub name_filter: SessionNameFilter,
    /// Whether to show working directory paths.
    pub show_paths: bool,
    /// Scroll offset for rendering.
    pub scroll_offset: usize,
    /// Maximum visible items.
    pub max_visible: usize,
    /// Current working directory for scope filtering.
    pub cwd: Option<String>,
}

impl SessionSelectorEnhanced {
    /// Create a new enhanced session selector.
    pub fn new(sessions: Vec<EnhancedSessionInfo>) -> Self {
        let filtered_sessions = sessions.clone();
        Self {
            sessions,
            filtered_sessions,
            query: String::new(),
            selected_index: 0,
            scope: SessionScope::CurrentFolder,
            sort_mode: SessionSortMode::Recent,
            name_filter: SessionNameFilter::All,
            show_paths: false,
            scroll_offset: 0,
            max_visible: 15,
            cwd: None,
        }
    }

    /// Set the current working directory for scope filtering.
    pub fn set_cwd(&mut self, cwd: String) {
        self.cwd = Some(cwd);
        self.apply_filter();
    }

    /// Toggle scope between current folder and all.
    pub fn toggle_scope(&mut self) {
        self.scope = match self.scope {
            SessionScope::CurrentFolder => SessionScope::All,
            SessionScope::All => SessionScope::CurrentFolder,
        };
        self.apply_filter();
    }

    /// Toggle sort mode.
    pub fn toggle_sort(&mut self) {
        self.sort_mode = match self.sort_mode {
            SessionSortMode::Recent => SessionSortMode::Threaded,
            SessionSortMode::Threaded => SessionSortMode::Fuzzy,
            SessionSortMode::Fuzzy => SessionSortMode::Recent,
        };
        self.apply_filter();
    }

    /// Toggle name filter.
    pub fn toggle_name_filter(&mut self) {
        self.name_filter = match self.name_filter {
            SessionNameFilter::All => SessionNameFilter::Named,
            SessionNameFilter::Named => SessionNameFilter::All,
        };
        self.apply_filter();
    }

    /// Set search filter.
    pub fn set_filter(&mut self, query: String) {
        self.query = query;
        self.apply_filter();
    }

    fn apply_filter(&mut self) {
        let mut filtered: Vec<EnhancedSessionInfo> = self.sessions.clone();

        // Scope filter
        if self.scope == SessionScope::CurrentFolder {
            if let Some(ref cwd) = self.cwd {
                filtered.retain(|s| s.working_dir.as_ref().map_or(true, |d| d == cwd));
            }
        }

        // Name filter
        if self.name_filter == SessionNameFilter::Named {
            filtered.retain(|s| s.name.is_empty());
            // Actually, keep named ones
            filtered = self
                .sessions
                .iter()
                .filter(|s| !s.name.is_empty())
                .cloned()
                .collect();
        }

        // Query filter
        if !self.query.is_empty() {
            let lower_query = self.query.to_lowercase();
            filtered.retain(|s| {
                s.name.to_lowercase().contains(&lower_query)
                    || s.id.to_lowercase().contains(&lower_query)
                    || s.label
                        .as_ref()
                        .map_or(false, |l| l.to_lowercase().contains(&lower_query))
            });
        }

        // Sort
        match self.sort_mode {
            SessionSortMode::Recent => {
                filtered.sort_by(|a, b| {
                    b.updated_at
                        .cmp(&a.updated_at)
                        .then(b.created_at.cmp(&a.created_at))
                });
            }
            SessionSortMode::Fuzzy => {
                // Keep order; fuzzy matching already done by filter
            }
            SessionSortMode::Threaded => {
                // Sort by parent_id to group threads
                filtered.sort_by(|a, b| {
                    a.parent_id
                        .cmp(&b.parent_id)
                        .then(b.created_at.cmp(&a.created_at))
                });
            }
        }

        self.filtered_sessions = filtered;
        self.selected_index = self
            .selected_index
            .min(self.filtered_sessions.len().saturating_sub(1));
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.filtered_sessions.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
            self.adjust_scroll();
        }
    }

    fn adjust_scroll(&mut self) {
        if self.selected_index < self.scroll_offset {
            self.scroll_offset = self.selected_index;
        } else if self.selected_index >= self.scroll_offset + self.max_visible {
            self.scroll_offset = self.selected_index - self.max_visible + 1;
        }
    }

    /// Get the currently selected session.
    pub fn selected(&self) -> Option<&EnhancedSessionInfo> {
        self.filtered_sessions.get(self.selected_index)
    }

    /// Render the enhanced session selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Header with scope, sort, name filter
        let title = match self.scope {
            SessionScope::CurrentFolder => "Resume Session (Current Folder)",
            SessionScope::All => "Resume Session (All)",
        };
        lines.push(format!("\x1b[1m{}\x1b[0m", title));

        let scope_text = match self.scope {
            SessionScope::CurrentFolder => "\x1b[36m◉ Current Folder\x1b[0m | ○ All",
            SessionScope::All => "○ Current Folder | \x1b[36m◉ All\x1b[0m",
        };
        let sort_text = format!("Sort: \x1b[36m{}\x1b[0m", self.sort_mode.label());
        let name_text = format!(
            "Name: \x1b[36m{}\x1b[0m",
            match self.name_filter {
                SessionNameFilter::All => "All",
                SessionNameFilter::Named => "Named",
            }
        );
        lines.push(format!("{}  {}  {}", scope_text, sort_text, name_text));

        // Search input
        if !self.query.is_empty() {
            lines.push(format!("> {}", self.query));
        }
        lines.push(String::new());

        if self.filtered_sessions.is_empty() {
            lines.push("\x1b[2m  No sessions found\x1b[0m".to_string());
            return lines;
        }

        // Render visible sessions
        let visible: Vec<_> = self
            .filtered_sessions
            .iter()
            .skip(self.scroll_offset)
            .take(self.max_visible)
            .collect();

        for (i, session) in visible.iter().enumerate() {
            let real_idx = self.scroll_offset + i;
            let is_selected = real_idx == self.selected_index;

            let marker = if is_selected {
                "\x1b[36m▶\x1b[0m"
            } else {
                " "
            };

            // Branch indicator
            let branch = if session.parent_id.is_some() {
                "├─ "
            } else {
                "  "
            };

            // Name or truncated ID
            let name = if session.name.is_empty() {
                &session.id[..8.min(session.id.len())]
            } else {
                &session.name
            };

            // Label
            let label = session
                .label
                .as_ref()
                .map(|l| format!(" \x1b[33m[{}]\x1b[0m", l))
                .unwrap_or_default();

            // Relative time
            let time =
                format_relative_time(&session.updated_at.as_ref().unwrap_or(&session.created_at));

            let mut line = format!(
                "{} {}{:<30} {} msg:{} model:{}",
                marker,
                branch,
                name,
                time,
                session.message_count,
                session.model.as_deref().unwrap_or("-"),
            );
            line.push_str(&label);

            // Working directory
            if self.show_paths {
                if let Some(ref dir) = session.working_dir {
                    line.push_str(&format!(" \x1b[2m{}\x1b[0m", shorten_path(dir)));
                }
            }

            lines.push(truncate_str(&line, width));
        }

        // Scroll indicator
        if self.filtered_sessions.len() > self.max_visible {
            lines.push(format!(
                "\x1b[2m  ({}/{})\x1b[0m",
                self.selected_index + 1,
                self.filtered_sessions.len()
            ));
        }

        lines
    }
}

// ── 4. SettingsSelector ──────────────────────────────────────────────────────

/// A setting item for the settings selector.
#[derive(Debug, Clone)]
pub struct SettingItem {
    /// Unique identifier for this setting.
    pub id: String,
    /// Display label.
    pub label: String,
    /// Description text.
    pub description: String,
    /// Current value as a string.
    pub current_value: String,
    /// Available values to choose from.
    pub values: Vec<String>,
}

impl SettingItem {
    pub fn new(
        id: impl Into<String>,
        label: impl Into<String>,
        description: impl Into<String>,
        current_value: impl Into<String>,
        values: Vec<String>,
    ) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            description: description.into(),
            current_value: current_value.into(),
            values,
        }
    }
}

/// Settings key-value selector with descriptions and fuzzy search.
#[derive(Debug, Clone)]
pub struct SettingsSelector {
    /// All settings items.
    pub items: Vec<SettingItem>,
    /// Filtered indices based on search.
    pub filtered_indices: Vec<usize>,
    /// Current search query.
    pub query: String,
    /// Selected index in filtered list.
    pub selected_index: usize,
    /// Scroll offset for rendering.
    pub scroll_offset: usize,
    /// Maximum visible items.
    pub max_visible: usize,
}

impl SettingsSelector {
    /// Create a new settings selector with the given items.
    pub fn new(items: Vec<SettingItem>) -> Self {
        let filtered_indices = (0..items.len()).collect();
        Self {
            items,
            filtered_indices,
            query: String::new(),
            selected_index: 0,
            scroll_offset: 0,
            max_visible: 10,
        }
    }

    /// Build settings items from a settings configuration.
    pub fn from_config(config: &SettingsConfig) -> Self {
        let mut items = Vec::new();

        items.push(SettingItem::new(
            "autocompact",
            "Auto-compact",
            "Automatically compact context when it gets too large",
            if config.auto_compact { "true" } else { "false" },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "show-images",
            "Show images",
            "Render images inline in terminal",
            if config.show_images { "true" } else { "false" },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "auto-resize-images",
            "Auto-resize images",
            "Resize large images to 2000x2000 max",
            if config.auto_resize_images {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "block-images",
            "Block images",
            "Prevent images from being sent to LLM providers",
            if config.block_images { "true" } else { "false" },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "skill-commands",
            "Skill commands",
            "Register skills as /skill:name commands",
            if config.enable_skill_commands {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "steering-mode",
            "Steering mode",
            "Enter while streaming queues steering messages",
            config.steering_mode.clone(),
            vec!["one-at-a-time".to_string(), "all".to_string()],
        ));

        items.push(SettingItem::new(
            "follow-up-mode",
            "Follow-up mode",
            "Alt+Enter queues follow-up messages until agent stops",
            config.follow_up_mode.clone(),
            vec!["one-at-a-time".to_string(), "all".to_string()],
        ));

        items.push(SettingItem::new(
            "transport",
            "Transport",
            "Preferred transport for providers with multiple transports",
            config.transport.clone(),
            vec![
                "sse".to_string(),
                "websocket".to_string(),
                "websocket-cached".to_string(),
                "auto".to_string(),
            ],
        ));

        items.push(SettingItem::new(
            "thinking",
            "Thinking level",
            "Reasoning depth for thinking-capable models",
            config.thinking_level.as_str().to_string(),
            config
                .available_thinking_levels
                .iter()
                .map(|l| l.as_str().to_string())
                .collect(),
        ));

        items.push(SettingItem::new(
            "hide-thinking",
            "Hide thinking",
            "Hide thinking blocks in assistant responses",
            if config.hide_thinking_block {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "theme",
            "Theme",
            "Color theme for the interface",
            config.current_theme.clone(),
            config.available_themes.clone(),
        ));

        items.push(SettingItem::new(
            "double-escape-action",
            "Double-escape action",
            "Action when pressing Escape twice with empty editor",
            config.double_escape_action.clone(),
            vec!["tree".to_string(), "fork".to_string(), "none".to_string()],
        ));

        items.push(SettingItem::new(
            "tree-filter-mode",
            "Tree filter mode",
            "Default filter when opening /tree",
            config.tree_filter_mode.clone(),
            vec![
                "default".to_string(),
                "no-tools".to_string(),
                "user-only".to_string(),
                "labeled-only".to_string(),
                "all".to_string(),
            ],
        ));

        items.push(SettingItem::new(
            "quiet-startup",
            "Quiet startup",
            "Disable verbose printing at startup",
            if config.quiet_startup {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "collapse-changelog",
            "Collapse changelog",
            "Show condensed changelog after updates",
            if config.collapse_changelog {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "install-telemetry",
            "Install telemetry",
            "Send an anonymous version/update ping after changelog-detected updates",
            if config.enable_install_telemetry {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "show-hardware-cursor",
            "Show hardware cursor",
            "Show the terminal cursor while still positioning it for IME support",
            if config.show_hardware_cursor {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "editor-padding",
            "Editor padding",
            "Horizontal padding for input editor (0-3)",
            config.editor_padding_x.to_string(),
            vec!["0".to_string(), "1".to_string(), "2".to_string(), "3".to_string()],
        ));

        items.push(SettingItem::new(
            "autocomplete-max-visible",
            "Autocomplete max items",
            "Max visible items in autocomplete dropdown (3-20)",
            config.autocomplete_max_visible.to_string(),
            vec!["3".to_string(), "5".to_string(), "7".to_string(), "10".to_string(), "15".to_string(), "20".to_string()],
        ));

        items.push(SettingItem::new(
            "clear-on-shrink",
            "Clear on shrink",
            "Clear empty rows when content shrinks (may cause flicker)",
            if config.clear_on_shrink {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "terminal-progress",
            "Terminal progress",
            "Show OSC 9;4 progress indicators in the terminal tab bar",
            if config.show_terminal_progress {
                "true"
            } else {
                "false"
            },
            vec!["true".to_string(), "false".to_string()],
        ));

        items.push(SettingItem::new(
            "image-width-cells",
            "Image width",
            "Preferred inline image width in terminal cells",
            config.image_width_cells.to_string(),
            vec!["60".to_string(), "80".to_string(), "120".to_string()],
        ));

        Self::new(items)
    }

    /// Set search filter.
    pub fn set_filter(&mut self, query: String) {
        self.query = query;
        self.apply_filter();
    }

    fn apply_filter(&mut self) {
        if self.query.is_empty() {
            self.filtered_indices = (0..self.items.len()).collect();
        } else {
            let search_texts: Vec<String> = self
                .items
                .iter()
                .map(|item| format!("{} {} {}", item.label, item.description, item.id))
                .collect();
            self.filtered_indices = fuzzy_filter_indices(&search_texts, &self.query);
        }
        self.selected_index = self
            .selected_index
            .min(self.filtered_indices.len().saturating_sub(1));
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.filtered_indices.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
            self.adjust_scroll();
        }
    }

    fn adjust_scroll(&mut self) {
        if self.selected_index < self.scroll_offset {
            self.scroll_offset = self.selected_index;
        } else if self.selected_index >= self.scroll_offset + self.max_visible {
            self.scroll_offset = self.selected_index - self.max_visible + 1;
        }
    }

    /// Get the currently selected setting item.
    pub fn selected(&self) -> Option<&SettingItem> {
        self.filtered_indices
            .get(self.selected_index)
            .and_then(|&idx| self.items.get(idx))
    }

    /// Cycle the value of the currently selected setting.
    pub fn cycle_value(&mut self) -> Option<String> {
        if let Some(&idx) = self.filtered_indices.get(self.selected_index) {
            let item = &mut self.items[idx];
            if item.values.len() > 1 {
                if let Some(pos) = item.values.iter().position(|v| v == &item.current_value) {
                    let next = (pos + 1) % item.values.len();
                    item.current_value = item.values[next].clone();
                    return Some(item.current_value.clone());
                }
            }
        }
        None
    }

    /// Set the value of a setting by ID.
    pub fn set_value(&mut self, id: &str, value: String) -> bool {
        if let Some(item) = self.items.iter_mut().find(|item| item.id == id) {
            if item.values.contains(&value) {
                item.current_value = value;
                return true;
            }
        }
        false
    }

    /// Validate the current settings.
    /// Returns a list of (setting_id, error_message) for invalid values.
    pub fn validate(&self) -> Vec<(String, String)> {
        let mut errors = Vec::new();
        for item in &self.items {
            match item.id.as_str() {
                "editor-padding" => {
                    if let Ok(v) = item.current_value.parse::<usize>() {
                        if v > 3 {
                            errors.push((item.id.clone(), "Editor padding must be 0-3".to_string()));
                        }
                    } else {
                        errors.push((item.id.clone(), "Editor padding must be a number".to_string()));
                    }
                }
                "autocomplete-max-visible" => {
                    if let Ok(v) = item.current_value.parse::<usize>() {
                        if v < 3 || v > 20 {
                            errors.push((item.id.clone(), "Autocomplete max visible must be 3-20".to_string()));
                        }
                    } else {
                        errors.push((item.id.clone(), "Autocomplete max visible must be a number".to_string()));
                    }
                }
                "image-width-cells" => {
                    if let Ok(v) = item.current_value.parse::<usize>() {
                        if v < 20 || v > 300 {
                            errors.push((item.id.clone(), "Image width must be 20-300".to_string()));
                        }
                    } else {
                        errors.push((item.id.clone(), "Image width must be a number".to_string()));
                    }
                }
                _ => {}
            }
        }
        errors
    }

    /// Render the settings selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push("\x1b[1mSettings\x1b[0m \x1b[2m(Enter to change · Esc to close · Type to search)\x1b[0m".to_string());

        if !self.query.is_empty() {
            lines.push(format!("Filter: {}", self.query));
        }
        lines.push(String::new());

        if self.filtered_indices.is_empty() {
            lines.push("\x1b[2m  No matching settings\x1b[0m".to_string());
            return lines;
        }

        // Render visible items
        let visible_count = self.max_visible.min(self.filtered_indices.len());
        for vi in self.scroll_offset..self.scroll_offset + visible_count {
            if vi >= self.filtered_indices.len() {
                break;
            }
            let idx = self.filtered_indices[vi];
            let item = &self.items[idx];
            let is_selected = vi == self.selected_index;

            let prefix = if is_selected {
                "\x1b[36m▶\x1b[0m"
            } else {
                " "
            };

            // Label and value
            let label = if is_selected {
                format!("\x1b[1m{}\x1b[0m", item.label)
            } else {
                item.label.clone()
            };

            // Show value with cycling indicator
            let value_display = if item.values.len() <= 2 {
                format!("\x1b[33m{}\x1b[0m", item.current_value)
            } else {
                // Show current value with position indicator
                if let Some(pos) = item.values.iter().position(|v| v == &item.current_value) {
                    format!(
                        "\x1b[33m{}\x1b[0m \x1b[2m({}/{})\x1b[0m",
                        item.current_value,
                        pos + 1,
                        item.values.len()
                    )
                } else {
                    format!("\x1b[33m{}\x1b[0m", item.current_value)
                }
            };

            let line = format!("{} {:<25} {}", prefix, label, value_display);
            lines.push(truncate_str(&line, width));

            // Description for selected item
            if is_selected {
                let desc = truncate_str(&format!("  \x1b[2m{}\x1b[0m", item.description), width);
                lines.push(desc);
            }
        }

        // Scroll indicator
        if self.filtered_indices.len() > self.max_visible {
            lines.push(format!(
                "\x1b[2m  ({}/{})\x1b[0m",
                self.selected_index + 1,
                self.filtered_indices.len()
            ));
        }

        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines
    }
}

/// Settings configuration for building the settings selector.
#[derive(Debug, Clone, Default)]
pub struct SettingsConfig {
    pub auto_compact: bool,
    pub show_images: bool,
    pub image_width_cells: usize,
    pub auto_resize_images: bool,
    pub block_images: bool,
    pub enable_skill_commands: bool,
    pub steering_mode: String,
    pub follow_up_mode: String,
    pub transport: String,
    pub thinking_level: ThinkingLevel,
    pub available_thinking_levels: Vec<ThinkingLevel>,
    pub current_theme: String,
    pub available_themes: Vec<String>,
    pub hide_thinking_block: bool,
    pub collapse_changelog: bool,
    pub enable_install_telemetry: bool,
    pub double_escape_action: String,
    pub tree_filter_mode: String,
    pub show_hardware_cursor: bool,
    pub editor_padding_x: usize,
    pub autocomplete_max_visible: usize,
    pub quiet_startup: bool,
    pub clear_on_shrink: bool,
    pub show_terminal_progress: bool,
}

// ── 5. ThemeSelector ─────────────────────────────────────────────────────────

/// Theme name selection with preview.
#[derive(Debug, Clone)]
pub struct ThemeSelector {
    /// Available theme names.
    pub themes: Vec<String>,
    /// Currently selected theme index.
    pub selected_index: usize,
    /// Current active theme name.
    pub current_theme: String,
    /// Filtered indices.
    pub filtered_indices: Vec<usize>,
    /// Search query.
    pub query: String,
    /// Maximum visible items.
    pub max_visible: usize,
}

impl ThemeSelector {
    /// Create a new theme selector.
    pub fn new(themes: Vec<String>, current_theme: String) -> Self {
        let filtered_indices = (0..themes.len()).collect();
        let selected_index = themes.iter().position(|t| t == &current_theme).unwrap_or(0);
        Self {
            themes,
            selected_index,
            current_theme,
            filtered_indices,
            query: String::new(),
            max_visible: 10,
        }
    }

    /// Set search filter.
    pub fn set_filter(&mut self, query: String) {
        self.query = query;
        self.apply_filter();
    }

    fn apply_filter(&mut self) {
        if self.query.is_empty() {
            self.filtered_indices = (0..self.themes.len()).collect();
        } else {
            self.filtered_indices = fuzzy_filter_indices(&self.themes, &self.query);
        }
        self.selected_index = self
            .selected_index
            .min(self.filtered_indices.len().saturating_sub(1));
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.filtered_indices.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
        }
    }

    /// Get the currently selected theme name.
    pub fn selected(&self) -> Option<&str> {
        self.filtered_indices
            .get(self.selected_index)
            .and_then(|&idx| self.themes.get(idx).map(|s| s.as_str()))
    }

    /// Render the theme selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push("\x1b[1mSelect Theme\x1b[0m".to_string());
        lines.push(String::new());

        if self.filtered_indices.is_empty() {
            lines.push("\x1b[2m  No matching themes\x1b[0m".to_string());
            return lines;
        }

        for (vi, &idx) in self.filtered_indices.iter().enumerate() {
            let theme = &self.themes[idx];
            let is_selected = vi == self.selected_index;
            let is_current = theme == &self.current_theme;

            let prefix = if is_selected {
                "\x1b[36m→ \x1b[0m"
            } else {
                "  "
            };

            let name = if is_selected {
                format!("\x1b[36m{}\x1b[0m", theme)
            } else {
                theme.clone()
            };

            let current_badge = if is_current {
                " \x1b[32m(current)\x1b[0m".to_string()
            } else {
                String::new()
            };

            let line = format!("{}{}{}", prefix, name, current_badge);
            lines.push(truncate_str(&line, width));

            // Preview: show sample colors for the theme
            if is_selected {
                lines.push(format!(
                    "    \x1b[2mPreview:\x1b[0m \x1b[32m●\x1b[0m \x1b[33m●\x1b[0m \x1b[36m●\x1b[0m \x1b[35m●\x1b[0m \x1b[31m●\x1b[0m"
                ));
            }
        }

        lines.push(String::new());
        lines.push("\x1b[2m  Enter to select · Esc to cancel\x1b[0m".to_string());
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());

        lines
    }
}

// ── 6. ThinkingSelector ─────────────────────────────────────────────────────

/// Thinking level selection (off/minimal/low/medium/high/xhigh).
#[derive(Debug, Clone)]
pub struct ThinkingSelector {
    /// Available thinking levels.
    pub levels: Vec<ThinkingLevel>,
    /// Currently selected level index.
    pub selected_index: usize,
    /// Current active thinking level.
    pub current_level: ThinkingLevel,
    /// Maximum thinking level allowed for the current model.
    pub model_max_level: Option<ThinkingLevel>,
    /// Model name for display.
    pub model_name: Option<String>,
}

impl ThinkingSelector {
    /// Create a new thinking selector.
    pub fn new(current_level: ThinkingLevel, available_levels: Vec<ThinkingLevel>) -> Self {
        let selected_index = available_levels
            .iter()
            .position(|l| l == &current_level)
            .unwrap_or(0);
        Self {
            levels: available_levels,
            selected_index,
            current_level,
            model_max_level: None,
            model_name: None,
        }
    }

    /// Create with all levels.
    pub fn new_with_all_levels(current_level: ThinkingLevel) -> Self {
        Self::new(current_level, ThinkingLevel::all())
    }

    /// Create with model-specific level clamping.
    ///
    /// If the model has a maximum thinking level, levels above it are
    /// shown but dimmed and marked as unavailable.
    pub fn new_with_model_clamp(
        current_level: ThinkingLevel,
        available_levels: Vec<ThinkingLevel>,
        model_max_level: ThinkingLevel,
        model_name: String,
    ) -> Self {
        // Clamp current level to model max
        let clamped_level = Self::clamp_level(current_level, model_max_level);
        let selected_index = available_levels
            .iter()
            .position(|l| l == &clamped_level)
            .unwrap_or(0);
        Self {
            levels: available_levels,
            selected_index,
            current_level: clamped_level,
            model_max_level: Some(model_max_level),
            model_name: Some(model_name),
        }
    }

    /// Clamp a thinking level to a maximum level.
    ///
    /// Off < Minimal < Low < Medium < High < XHigh
    pub fn clamp_level(level: ThinkingLevel, max: ThinkingLevel) -> ThinkingLevel {
        if level.rank() > max.rank() {
            max
        } else {
            level
        }
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.levels.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
        }
    }

    /// Get the currently selected thinking level.
    pub fn selected(&self) -> Option<ThinkingLevel> {
        self.levels.get(self.selected_index).copied()
    }

    /// Check if a level is available for the current model.
    pub fn is_level_available(&self, level: ThinkingLevel) -> bool {
        match self.model_max_level {
            Some(max) => level.rank() <= max.rank(),
            None => true,
        }
    }

    /// Render the thinking selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push("\x1b[1mThinking Level\x1b[0m".to_string());
        lines.push("\x1b[2mSelect reasoning depth for thinking-capable models\x1b[0m".to_string());

        // Show model name if set
        if let Some(ref model) = self.model_name {
            lines.push(format!("\x1b[2mModel: \x1b[36m{}\x1b[0m", model));
        }

        lines.push(String::new());

        for (i, level) in self.levels.iter().enumerate() {
            let is_selected = i == self.selected_index;
            let is_current = *level == self.current_level;
            let is_available = self.is_level_available(*level);

            let prefix = if is_selected {
                "\x1b[36m→ \x1b[0m"
            } else {
                "  "
            };

            let name = if !is_available {
                format!("\x1b[2m{}\x1b[0m", level.as_str())
            } else if is_selected {
                format!("\x1b[36m{}\x1b[0m", level.as_str())
            } else {
                level.as_str().to_string()
            };

            let desc = if !is_available {
                format!("\x1b[31m(not supported)\x1b[0m")
            } else {
                format!("\x1b[2m{}\x1b[0m", level.description())
            };

            let current_badge = if is_current {
                " \x1b[32m✓\x1b[0m".to_string()
            } else {
                String::new()
            };

            let clamped_marker = if is_current && *level != self.current_level {
                " \x1b[33m(clamped)\x1b[0m".to_string()
            } else {
                String::new()
            };

            let line = format!("{}{:<12} {}{}{}", prefix, name, desc, current_badge, clamped_marker);
            lines.push(truncate_str(&line, width));
        }

        lines.push(String::new());
        lines.push("\x1b[2m  Enter to select · Esc to cancel\x1b[0m".to_string());
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());

        lines
    }
}

// ── 7. TreeSelector ─────────────────────────────────────────────────────────

/// A tree node for session tree navigation.
#[derive(Debug, Clone)]
pub struct SessionTreeNode {
    /// Unique entry ID.
    pub id: String,
    /// Display label for this node.
    pub label: String,
    /// Parent node ID.
    pub parent_id: Option<String>,
    /// Whether this is a user message.
    pub is_user: bool,
    /// Whether this is a tool call.
    pub is_tool: bool,
    /// Whether this node has a custom label.
    pub has_label: bool,
    /// Custom label text (if has_label is true).
    pub custom_label: Option<String>,
    /// Timestamp string.
    pub timestamp: Option<String>,
    /// Child nodes.
    pub children: Vec<SessionTreeNode>,
}

/// Filter mode for tree display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeFilterMode {
    Default,
    NoTools,
    UserOnly,
    LabeledOnly,
    All,
}

impl TreeFilterMode {
    pub fn label(&self) -> &'static str {
        match self {
            TreeFilterMode::Default => "default",
            TreeFilterMode::NoTools => "no-tools",
            TreeFilterMode::UserOnly => "user-only",
            TreeFilterMode::LabeledOnly => "labeled-only",
            TreeFilterMode::All => "all",
        }
    }

    /// Get all filter modes in order.
    pub fn all_modes() -> &'static [TreeFilterMode] {
        &[
            TreeFilterMode::Default,
            TreeFilterMode::NoTools,
            TreeFilterMode::UserOnly,
            TreeFilterMode::LabeledOnly,
            TreeFilterMode::All,
        ]
    }

    /// Cycle to the next filter mode.
    pub fn next(&self) -> TreeFilterMode {
        let modes = Self::all_modes();
        let idx = modes.iter().position(|m| m == self).unwrap_or(0);
        modes[(idx + 1) % modes.len()]
    }

    /// Cycle to the previous filter mode.
    pub fn prev(&self) -> TreeFilterMode {
        let modes = Self::all_modes();
        let idx = modes.iter().position(|m| m == self).unwrap_or(0);
        modes[(idx + modes.len() - 1) % modes.len()]
    }
}

/// Gutter info for ASCII tree rendering.
/// Tracks where vertical connector lines should appear.
#[derive(Debug, Clone)]
pub struct GutterInfo {
    /// Position (indent level) where the connector was shown.
    pub position: usize,
    /// Whether to show │ (true) or spaces (false).
    pub show: bool,
}

/// Flattened tree node for display.
#[derive(Debug, Clone)]
pub struct FlatTreeNode {
    /// Reference to the tree node ID.
    pub node_id: String,
    /// Display label.
    pub label: String,
    /// Custom label text.
    pub custom_label: Option<String>,
    /// Indent level (each level = 3 chars).
    pub indent: usize,
    /// Whether this node is the last sibling.
    pub is_last: bool,
    /// Whether to show a connector line.
    pub show_connector: bool,
    /// Gutter info for ancestor branch points.
    pub gutters: Vec<GutterInfo>,
    /// Whether this is a user message.
    pub is_user: bool,
    /// Whether this is a tool call.
    pub is_tool: bool,
    /// Whether this node is on the active path.
    pub is_active: bool,
    /// Whether this node has a custom label.
    pub has_label: bool,
}

/// Session tree navigation with branch display, fold/unfold, and filter support.
///
/// Ported from `tree-selector.ts` with:
/// - ASCII tree display with proper gutter/connector rendering
/// - Branch/leaf indicators (├─ └─ │)
/// - Entry type icons (user=●, tool=⚙, assistant=○)
/// - Active path markers (•)
/// - Filter by type (default/no-tools/user-only/labeled-only/all)
/// - Fold/unfold support
/// - Inline search
#[derive(Debug, Clone)]
pub struct TreeSelector {
    /// Root tree nodes.
    pub roots: Vec<SessionTreeNode>,
    /// Flattened nodes for display.
    pub flat_nodes: Vec<FlatTreeNode>,
    /// Filtered nodes based on filter mode.
    pub filtered_nodes: Vec<FlatTreeNode>,
    /// Currently selected index in filtered_nodes.
    pub selected_index: usize,
    /// Current leaf ID (active entry).
    pub current_leaf_id: Option<String>,
    /// Active path IDs (from root to current leaf).
    pub active_path_ids: std::collections::HashSet<String>,
    /// Current filter mode.
    pub filter_mode: TreeFilterMode,
    /// Search query.
    pub query: String,
    /// Maximum visible items.
    pub max_visible: usize,
    /// Set of folded node IDs.
    pub folded_nodes: std::collections::HashSet<String>,
    /// Scroll offset for rendering.
    pub scroll_offset: usize,
}

impl TreeSelector {
    /// Create a new tree selector from root nodes.
    pub fn new(roots: Vec<SessionTreeNode>, current_leaf_id: Option<String>) -> Self {
        let active_path_ids = Self::build_active_path(&roots, current_leaf_id.as_deref());
        let flat_nodes = Self::flatten_tree(&roots, &active_path_ids);
        let selected_index = current_leaf_id
            .as_ref()
            .and_then(|id| flat_nodes.iter().position(|n| n.node_id == *id))
            .unwrap_or_else(|| flat_nodes.iter().position(|n| !n.is_tool).unwrap_or(0));
        let filtered_nodes = flat_nodes.clone();

        Self {
            roots,
            flat_nodes,
            filtered_nodes,
            selected_index,
            current_leaf_id,
            active_path_ids,
            filter_mode: TreeFilterMode::Default,
            query: String::new(),
            max_visible: 20,
            folded_nodes: std::collections::HashSet::new(),
            scroll_offset: 0,
        }
    }

    /// Create a tree selector with an initial filter mode.
    pub fn new_with_filter_mode(
        roots: Vec<SessionTreeNode>,
        current_leaf_id: Option<String>,
        initial_filter_mode: TreeFilterMode,
    ) -> Self {
        let mut sel = Self::new(roots, current_leaf_id);
        sel.filter_mode = initial_filter_mode;
        sel.apply_filter();
        sel
    }

    fn build_active_path(
        roots: &[SessionTreeNode],
        leaf_id: Option<&str>,
    ) -> std::collections::HashSet<String> {
        let mut path = std::collections::HashSet::new();
        if let Some(leaf) = leaf_id {
            fn walk(
                node: &SessionTreeNode,
                target: &str,
                path: &mut std::collections::HashSet<String>,
            ) -> bool {
                path.insert(node.id.clone());
                if node.id == target {
                    return true;
                }
                for child in &node.children {
                    if walk(child, target, path) {
                        return true;
                    }
                }
                path.remove(&node.id);
                false
            }

            for root in roots {
                if walk(root, leaf, &mut path) {
                    break;
                }
            }
        }
        path
    }

    fn flatten_tree(
        roots: &[SessionTreeNode],
        active_path_ids: &std::collections::HashSet<String>,
    ) -> Vec<FlatTreeNode> {
        let mut result = Vec::new();
        let multiple_roots = roots.len() > 1;

        // Determine which subtrees contain the active leaf
        let contains_active = |node: &SessionTreeNode| -> bool {
            fn check(n: &SessionTreeNode, target_id: &str) -> bool {
                if n.id == target_id {
                        return true;
                    }
                    n.children.iter().any(|c| check(c, target_id))
                }
            // Check if any ID in active_path_ids is in this subtree
            for id in active_path_ids.iter() {
                if node.id == *id || node.children.iter().any(|c| check(c, id)) {
                    return true;
                }
            }
            false
        };

        fn flatten_recursive(
            node: &SessionTreeNode,
            indent: usize,
            is_last: bool,
            show_connector: bool,
            gutters: Vec<GutterInfo>,
            result: &mut Vec<FlatTreeNode>,
            active_path_ids: &std::collections::HashSet<String>,
            _multiple_children: bool,
            just_branched: bool,
        ) {
            result.push(FlatTreeNode {
                node_id: node.id.clone(),
                label: node.label.clone(),
                custom_label: node.custom_label.clone(),
                indent,
                is_last,
                show_connector,
                gutters: gutters.clone(),
                is_user: node.is_user,
                is_tool: node.is_tool,
                is_active: active_path_ids.contains(&node.id),
                has_label: node.has_label,
            });

            let multiple = node.children.len() > 1;
            for (i, child) in node.children.iter().enumerate() {
                let child_is_last = i == node.children.len() - 1;

                // Calculate child indent
                let child_indent = if multiple {
                    indent + 1
                } else if just_branched && indent > 0 {
                    indent + 1
                } else {
                    indent
                };

                // Build child gutters
                let connector_shown = show_connector && !result.is_empty();
                let connector_pos = if indent > 0 { indent - 1 } else { 0 };
                let mut child_gutters = gutters.clone();
                if connector_shown {
                    child_gutters.push(GutterInfo {
                        position: connector_pos,
                        show: !is_last,
                    });
                }

                flatten_recursive(
                    child,
                    child_indent,
                    child_is_last,
                    multiple,
                    child_gutters,
                    result,
                    active_path_ids,
                    multiple,
                    multiple,
                );
            }
        }

        // Sort roots so the one containing the active leaf comes first
        let mut ordered_roots: Vec<&SessionTreeNode> = roots.iter().collect();
        ordered_roots.sort_by(|a, b| {
            let a_active = contains_active(a);
            let b_active = contains_active(b);
            b_active.cmp(&a_active)
        });

        for (i, root) in ordered_roots.iter().enumerate() {
            let is_last = i == ordered_roots.len() - 1;
            let indent = if multiple_roots { 1 } else { 0 };
            flatten_recursive(
                root,
                indent,
                is_last,
                multiple_roots,
                Vec::new(),
                &mut result,
                active_path_ids,
                false,
                multiple_roots,
            );
        }

        result
    }

    /// Set the filter mode.
    pub fn set_filter_mode(&mut self, mode: TreeFilterMode) {
        self.filter_mode = mode;
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Cycle filter mode forward.
    pub fn cycle_filter_forward(&mut self) {
        self.filter_mode = self.filter_mode.next();
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Cycle filter mode backward.
    pub fn cycle_filter_backward(&mut self) {
        self.filter_mode = self.filter_mode.prev();
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Toggle between a specific filter and default.
    pub fn toggle_filter(&mut self, mode: TreeFilterMode) {
        if self.filter_mode == mode {
            self.filter_mode = TreeFilterMode::Default;
        } else {
            self.filter_mode = mode;
        }
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Set search query.
    pub fn set_query(&mut self, query: String) {
        self.query = query;
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Append a character to the search query.
    pub fn append_search(&mut self, ch: char) {
        self.query.push(ch);
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Delete last character from search query.
    pub fn backspace_search(&mut self) {
        self.query.pop();
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Clear search query.
    pub fn clear_search(&mut self) {
        self.query.clear();
        self.folded_nodes.clear();
        self.apply_filter();
    }

    /// Toggle fold on the currently selected node.
    pub fn toggle_fold(&mut self) {
        if let Some(node_id) = self.selected_id().map(|s| s.to_string()) {
            if self.folded_nodes.contains(&node_id) {
                self.folded_nodes.remove(&node_id);
            } else {
                self.folded_nodes.insert(node_id);
            }
            self.apply_filter();
        }
    }

    /// Fold the currently selected node.
    pub fn fold(&mut self) {
        if let Some(node_id) = self.selected_id().map(|s| s.to_string()) {
            self.folded_nodes.insert(node_id);
            self.apply_filter();
        }
    }

    /// Unfold the currently selected node.
    pub fn unfold(&mut self) {
        if let Some(node_id) = self.selected_id().map(|s| s.to_string()) {
            self.folded_nodes.remove(&node_id);
            self.apply_filter();
        }
    }

    fn apply_filter(&mut self) {
        // Preserve last selected ID
        let last_selected_id = self
            .filtered_nodes
            .get(self.selected_index)
            .map(|n| n.node_id.clone());

        let search_tokens: Vec<String> = self
            .query
            .to_lowercase()
            .split_whitespace()
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string())
            .collect();

        self.filtered_nodes = self
            .flat_nodes
            .iter()
            .filter(|node| {
                // Filter mode
                match self.filter_mode {
                    TreeFilterMode::Default => true,
                    TreeFilterMode::NoTools => !node.is_tool,
                    TreeFilterMode::UserOnly => node.is_user,
                    TreeFilterMode::LabeledOnly => node.has_label,
                    TreeFilterMode::All => true,
                }
            })
            .filter(|node| {
                // Query filter
                if search_tokens.is_empty() {
                    return true;
                }
                let lower_label = node.label.to_lowercase();
                let lower_id = node.node_id.to_lowercase();
                search_tokens
                    .iter()
                    .all(|token| lower_label.contains(token) || lower_id.contains(token))
            })
            .cloned()
            .collect();

        // Filter out descendants of folded nodes
        if !self.folded_nodes.is_empty() {
            let mut _skip_set: std::collections::HashSet<String> = std::collections::HashSet::new();
            for flat_node in &self.flat_nodes {
                // Walk up to check if any ancestor is folded
                let current_id = flat_node.node_id.as_str();
                // We need parent info - use flat_nodes parent tracking
                // For simplicity, check if the node's ID or any ancestor in folded set
                if self.folded_nodes.contains(current_id) {
                    // Mark this node's children for skipping (handled below)
                }
            }
            // Build parent map from flat_nodes
            let mut _parent_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
            for _node in &self.flat_nodes {
                // Use indent to determine parent-child relationships
                // Children have higher indent than their parent
            }
            // Simple approach: remove filtered nodes whose ancestors are folded
            let mut new_filtered = Vec::new();
            let mut folded_depth = std::collections::HashMap::new();
            for node in &self.filtered_nodes {
                let indent = node.indent;
                // Remove any folded depth >= current indent
                folded_depth.retain(|&k, _| k < indent);
                if folded_depth.is_empty() || folded_depth.keys().all(|&k| k >= indent) {
                    if self.folded_nodes.contains(&node.node_id) {
                        folded_depth.insert(indent, true);
                    }
                    new_filtered.push(node.clone());
                }
            }
            self.filtered_nodes = new_filtered;
        }

        // Try to restore selection
        self.selected_index = if let Some(ref id) = last_selected_id {
            self.filtered_nodes
                .iter()
                .position(|n| n.node_id == *id)
                .unwrap_or_else(|| {
                    self.filtered_nodes
                        .len()
                        .saturating_sub(1)
                })
        } else {
            self.selected_index
                .min(self.filtered_nodes.len().saturating_sub(1))
        };
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let max = self.filtered_nodes.len().saturating_sub(1);
        if self.selected_index < max {
            self.selected_index += 1;
            self.adjust_scroll();
        }
    }

    /// Move selection up by a page.
    pub fn page_up(&mut self) {
        self.selected_index = self.selected_index.saturating_sub(self.max_visible);
        self.adjust_scroll();
    }

    /// Move selection down by a page.
    pub fn page_down(&mut self) {
        let max = self.filtered_nodes.len().saturating_sub(1);
        self.selected_index = (self.selected_index + self.max_visible).min(max);
        self.adjust_scroll();
    }

    fn adjust_scroll(&mut self) {
        if self.selected_index < self.scroll_offset {
            self.scroll_offset = self.selected_index;
        } else if self.selected_index >= self.scroll_offset + self.max_visible {
            self.scroll_offset = self.selected_index - self.max_visible + 1;
        }
    }

    /// Get the currently selected node ID.
    pub fn selected_id(&self) -> Option<&str> {
        self.filtered_nodes
            .get(self.selected_index)
            .map(|n| n.node_id.as_str())
    }

    /// Get the status label for the current filter mode.
    fn get_status_labels(&self) -> String {
        let mut labels = String::new();
        match self.filter_mode {
            TreeFilterMode::NoTools => labels.push_str(" [no-tools]"),
            TreeFilterMode::UserOnly => labels.push_str(" [user]"),
            TreeFilterMode::LabeledOnly => labels.push_str(" [labeled]"),
            TreeFilterMode::All => labels.push_str(" [all]"),
            TreeFilterMode::Default => {}
        }
        labels
    }

    /// Render the tree selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Header
        lines.push(String::new());
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push("\x1b[1m  Session Tree\x1b[0m".to_string());

        // Search line
        if self.query.is_empty() {
            lines.push("\x1b[2m  Type to search:\x1b[0m".to_string());
        } else {
            lines.push(format!(
                "  \x1b[2mType to search:\x1b[0m \x1b[36m{}\x1b[0m",
                self.query
            ));
        }

        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());

        if self.filtered_nodes.is_empty() {
            lines.push("\x1b[2m  No entries found\x1b[0m".to_string());
            lines.push(format!(
                "\x1b[2m  (0/0){}\x1b[0m",
                self.get_status_labels()
            ));
            lines
                .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
            return lines;
        }

        // Calculate visible range
        let total = self.filtered_nodes.len();
        let start = self.scroll_offset;
        let end = (start + self.max_visible).min(total);

        for i in start..end {
            let node = &self.filtered_nodes[i];
            let is_selected = i == self.selected_index;
            let is_folded = self.folded_nodes.contains(&node.node_id);

            // Cursor
            let cursor = if is_selected {
                "\x1b[36m› \x1b[0m"
            } else {
                "  "
            };

            // Build prefix with gutters
            let multiple_roots = self.roots.len() > 1;
            let display_indent = if multiple_roots {
                node.indent.saturating_sub(1)
            } else {
                node.indent
            };

            let connector = if node.show_connector {
                if node.is_last {
                    "└─ "
                } else {
                    "├─ "
                }
            } else {
                ""
            };

            // Build indent with gutter lines
            let mut prefix_chars = String::new();
            let total_chars = display_indent * 3;

            for pos in 0..total_chars {
                let level = pos / 3;
                let pos_in_level = pos % 3;

                // Check if there's a gutter at this level
                if let Some(gutter) = node.gutters.iter().find(|g| g.position == level) {
                    if pos_in_level == 0 {
                        prefix_chars.push(if gutter.show { '│' } else { ' ' });
                    } else {
                        prefix_chars.push(' ');
                    }
                } else {
                    prefix_chars.push(' ');
                }
            }

            // Fold marker for nodes without connectors (roots)
            let fold_marker = if is_folded && !node.show_connector {
                "\x1b[36m⊞ \x1b[0m"
            } else {
                ""
            };

            // Fold indicator in connector
            let connector_display = if node.show_connector && is_folded {
                // Replace last char of connector with fold marker
                let base = if node.is_last { "└" } else { "├" };
                format!("{}⊞ ", base)
            } else {
                connector.to_string()
            };

            // Active path marker
            let path_marker = if node.is_active {
                "\x1b[36m• \x1b[0m"
            } else {
                ""
            };

            // Custom label
            let label = node
                .custom_label
                .as_ref()
                .map(|l| format!("\x1b[33m[{}] \x1b[0m", l))
                .unwrap_or_default();

            // Node icon based on type
            let icon = if node.is_user {
                "\x1b[36m●\x1b[0m"
            } else if node.is_tool {
                "\x1b[33m⚙\x1b[0m"
            } else {
                "\x1b[2m○\x1b[0m"
            };

            // Display label
            let display_label = if is_selected {
                format!("\x1b[1m\x1b[36m{}\x1b[0m", node.label)
            } else if node.is_active {
                format!("\x1b[36m{}\x1b[0m", node.label)
            } else {
                node.label.clone()
            };

            let line = format!(
                "{}\x1b[2m{}\x1b[0m{}{}{}{}{}{}",
                cursor,
                prefix_chars,
                connector_display,
                fold_marker,
                path_marker,
                label,
                icon,
                display_label
            );

            if is_selected {
                // Apply selected background
                lines.push(truncate_str(
                    &format!("\x1b[48;5;24m {}\x1b[0m", &line[..line.len().min(width)]),
                    width,
                ));
            } else {
                lines.push(truncate_str(&line, width));
            }
        }

        // Status line
        lines.push(format!(
            "\x1b[2m  ({}/{}){}\x1b[0m",
            self.selected_index + 1,
            total,
            self.get_status_labels()
        ));

        // Hints
        lines.push(String::new());
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push(
            "\x1b[2m  ↑/↓: move · ←/→: page · Enter: jump · Esc: close · F: filter · Tab: cycle\x1b[0m"
                .to_string(),
        );
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());

        lines
    }
}

// ── 8. ScopedModelsSelector ─────────────────────────────────────────────────

/// A model item for scoped model selection.
#[derive(Debug, Clone)]
pub struct ScopedModelItem {
    /// Full identifier (provider/id).
    pub full_id: String,
    /// Model ID.
    pub id: String,
    /// Provider name.
    pub provider: String,
    /// Human-readable model name.
    pub name: String,
    /// Whether this model is enabled for cycling.
    pub enabled: bool,
}

/// Scoped models cycling display for Ctrl+P model switching.
#[derive(Debug, Clone)]
pub struct ScopedModelsSelector {
    /// All model items.
    pub all_models: Vec<ScopedModelItem>,
    /// All model IDs in order.
    pub all_ids: Vec<String>,
    /// Enabled model IDs (null = all enabled).
    pub enabled_ids: Option<Vec<String>>,
    /// Filtered items based on search.
    pub filtered_items: Vec<ScopedModelItem>,
    /// Currently selected index.
    pub selected_index: usize,
    /// Current search query.
    pub query: String,
    /// Maximum visible items.
    pub max_visible: usize,
    /// Whether there are unsaved changes.
    pub is_dirty: bool,
}

impl ScopedModelsSelector {
    /// Create a new scoped models selector.
    pub fn new(models: Vec<ScopedModelItem>, enabled_ids: Option<Vec<String>>) -> Self {
        let all_ids: Vec<String> = models.iter().map(|m| m.full_id.clone()).collect();
        let filtered_items = Self::build_items(&models, &enabled_ids, &all_ids);

        Self {
            all_models: models,
            all_ids,
            enabled_ids,
            filtered_items,
            selected_index: 0,
            query: String::new(),
            max_visible: 8,
            is_dirty: false,
        }
    }

    fn is_enabled(&self, id: &str) -> bool {
        match &self.enabled_ids {
            None => true,
            Some(ids) => ids.contains(&id.to_string()),
        }
    }

    fn build_items(
        models: &[ScopedModelItem],
        enabled_ids: &Option<Vec<String>>,
        all_ids: &[String],
    ) -> Vec<ScopedModelItem> {
        let sorted_ids = Self::get_sorted_ids(enabled_ids, all_ids);
        sorted_ids
            .into_iter()
            .filter_map(|id| {
                models
                    .iter()
                    .find(|m| m.full_id == id)
                    .map(|m| ScopedModelItem {
                        full_id: m.full_id.clone(),
                        id: m.id.clone(),
                        provider: m.provider.clone(),
                        name: m.name.clone(),
                        enabled: match enabled_ids {
                            None => true,
                            Some(ids) => ids.contains(&m.full_id),
                        },
                    })
            })
            .collect()
    }

    fn get_sorted_ids(enabled_ids: &Option<Vec<String>>, all_ids: &[String]) -> Vec<String> {
        match enabled_ids {
            None => all_ids.to_vec(),
            Some(ids) => {
                let enabled_set: std::collections::HashSet<_> = ids.iter().collect();
                let mut result = ids.clone();
                for id in all_ids {
                    if !enabled_set.contains(id) {
                        result.push(id.clone());
                    }
                }
                result
            }
        }
    }

    /// Toggle a model's enabled state.
    pub fn toggle(&mut self, full_id: &str) {
        self.enabled_ids = match &mut self.enabled_ids {
            None => Some(vec![full_id.to_string()]),
            Some(ids) => {
                if let Some(pos) = ids.iter().position(|id| id == full_id) {
                    ids.remove(pos);
                } else {
                    ids.push(full_id.to_string());
                }
                if ids.len() == self.all_ids.len() {
                    // All enabled -> null
                    None
                } else {
                    let ids = ids.clone();
                    Some(ids)
                }
            }
        };
        self.is_dirty = true;
        self.refresh();
    }

    /// Enable all models.
    pub fn enable_all(&mut self) {
        self.enabled_ids = None;
        self.is_dirty = true;
        self.refresh();
    }

    /// Clear all models (disable all).
    pub fn clear_all(&mut self) {
        self.enabled_ids = Some(Vec::new());
        self.is_dirty = true;
        self.refresh();
    }

    /// Toggle all models from a specific provider.
    pub fn toggle_provider(&mut self, provider: &str) {
        let provider_ids: Vec<String> = self
            .all_ids
            .iter()
            .filter(|id| {
                self.all_models
                    .iter()
                    .any(|m| &m.full_id == *id && m.provider == provider)
            })
            .cloned()
            .collect();

        let all_enabled = provider_ids.iter().all(|id| self.is_enabled(id));

        if all_enabled {
            // Disable all from provider
            self.enabled_ids = match &self.enabled_ids {
                None => Some(
                    self.all_ids
                        .iter()
                        .filter(|id| !provider_ids.contains(id))
                        .cloned()
                        .collect(),
                ),
                Some(ids) => Some(
                    ids.iter()
                        .filter(|id| !provider_ids.contains(id))
                        .cloned()
                        .collect(),
                ),
            };
        } else {
            // Enable all from provider
            self.enabled_ids = match &mut self.enabled_ids {
                None => None,
                Some(ids) => {
                    for pid in &provider_ids {
                        if !ids.contains(pid) {
                            ids.push(pid.clone());
                        }
                    }
                    if ids.len() == self.all_ids.len() {
                        None
                    } else {
                        let ids = ids.clone();
                        Some(ids)
                    }
                }
            };
        }
        self.is_dirty = true;
        self.refresh();
    }

    /// Move a model up in the enabled order.
    pub fn reorder_up(&mut self) {
        if let Some(ref mut ids) = self.enabled_ids {
            if let Some(item) = self.filtered_items.get(self.selected_index) {
                if let Some(pos) = ids.iter().position(|id| id == &item.full_id) {
                    if pos > 0 {
                        ids.swap(pos, pos - 1);
                        self.is_dirty = true;
                        self.refresh();
                        self.move_up();
                    }
                }
            }
        }
    }

    /// Move a model down in the enabled order.
    pub fn reorder_down(&mut self) {
        if let Some(ref mut ids) = self.enabled_ids {
            if let Some(item) = self.filtered_items.get(self.selected_index) {
                if let Some(pos) = ids.iter().position(|id| id == &item.full_id) {
                    if pos < ids.len() - 1 {
                        ids.swap(pos, pos + 1);
                        self.is_dirty = true;
                        self.refresh();
                        self.move_down();
                    }
                }
            }
        }
    }

    /// Set search filter.
    pub fn set_filter(&mut self, query: String) {
        self.query = query;
        self.refresh();
    }

    fn refresh(&mut self) {
        let items = Self::build_items(&self.all_models, &self.enabled_ids, &self.all_ids);
        if self.query.is_empty() {
            self.filtered_items = items;
        } else {
            let indices = fuzzy_filter_indices(
                &items
                    .iter()
                    .map(|i| format!("{} {}", i.id, i.provider))
                    .collect::<Vec<_>>(),
                &self.query,
            );
            self.filtered_items = indices.into_iter().map(|i| items[i].clone()).collect();
        }
        self.selected_index = self
            .selected_index
            .min(self.filtered_items.len().saturating_sub(1));
    }

    /// Move selection up (wraps).
    pub fn move_up(&mut self) {
        if self.filtered_items.is_empty() {
            return;
        }
        if self.selected_index == 0 {
            self.selected_index = self.filtered_items.len() - 1;
        } else {
            self.selected_index -= 1;
        }
    }

    /// Move selection down (wraps).
    pub fn move_down(&mut self) {
        if self.filtered_items.is_empty() {
            return;
        }
        if self.selected_index == self.filtered_items.len() - 1 {
            self.selected_index = 0;
        } else {
            self.selected_index += 1;
        }
    }

    /// Get the currently selected model.
    pub fn selected(&self) -> Option<&ScopedModelItem> {
        self.filtered_items.get(self.selected_index)
    }

    /// Mark changes as saved.
    pub fn mark_saved(&mut self) {
        self.is_dirty = false;
    }

    /// Render the scoped models selector.
    pub fn render(&self, width: usize) -> Vec<String> {
        let mut lines = Vec::new();

        // Header
        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());
        lines.push("\x1b[1m\x1b[36mModel Configuration\x1b[0m".to_string());
        lines.push("\x1b[2mSession-only. Ctrl+S to save to settings.\x1b[0m".to_string());
        lines.push(String::new());

        // Search input
        lines.push(format!("> {}", self.query));
        lines.push(String::new());

        if self.filtered_items.is_empty() {
            lines.push("\x1b[2m  No matching models\x1b[0m".to_string());
            return lines;
        }

        // Calculate visible range
        let total = self.filtered_items.len();
        let half = self.max_visible / 2;
        let start = if self.selected_index >= half {
            (self.selected_index - half).min(total.saturating_sub(self.max_visible))
        } else {
            0
        };
        let end = (start + self.max_visible).min(total);
        let all_enabled = self.enabled_ids.is_none();

        for i in start..end {
            let item = &self.filtered_items[i];
            let is_selected = i == self.selected_index;

            let prefix = if is_selected {
                "\x1b[36m→ \x1b[0m"
            } else {
                "  "
            };

            let model_text = if is_selected {
                format!("\x1b[36m{}\x1b[0m", item.id)
            } else {
                item.id.clone()
            };

            let provider_badge = format!("\x1b[2m [{}]\x1b[0m", item.provider);

            let status = if all_enabled {
                String::new()
            } else if item.enabled {
                "\x1b[32m ✓\x1b[0m".to_string()
            } else {
                "\x1b[2m ✗\x1b[0m".to_string()
            };

            let line = format!("{}{}{}{}", prefix, model_text, provider_badge, status);
            lines.push(truncate_str(&line, width));
        }

        // Scroll indicator
        if start > 0 || end < total {
            lines.push(format!(
                "\x1b[2m  ({}/{})\x1b[0m",
                self.selected_index + 1,
                total
            ));
        }

        // Detail line
        if let Some(selected) = self.selected() {
            lines.push(String::new());
            lines.push(format!("\x1b[2m  Model Name: {}\x1b[0m", selected.name));
        }

        // Footer hints
        lines.push(String::new());
        let enabled_count = self
            .enabled_ids
            .as_ref()
            .map_or_else(|| self.all_ids.len(), |ids| ids.len());
        let count_text = if all_enabled {
            "all enabled".to_string()
        } else {
            format!("{}/{} enabled", enabled_count, self.all_ids.len())
        };
        let dirty_marker = if self.is_dirty {
            "\x1b[33m(unsaved)\x1b[0m"
        } else {
            ""
        };
        lines.push(format!(
            "\x1b[2m  Enter=toggle · Ctrl+A=all · Ctrl+X=clear · ↑↓=reorder · {} {}\x1b[0m",
            count_text, dirty_marker
        ));

        lines
            .push("\x1b[36m───────────────────────────────────────────────────\x1b[0m".to_string());

        lines
    }
}

// ── OAuth Tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod oauth_tests {
    use super::*;

    #[test]
    fn test_code_verifier_length() {
        let v = generate_code_verifier();
        assert!((43..=128).contains(&v.len()), "verifier length {}", v.len());
    }

    #[test]
    fn test_code_verifier_is_base64url() {
        let v = generate_code_verifier();
        // base64url chars: A-Z a-z 0-9 - _
        assert!(v
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
    }

    #[test]
    fn test_code_verifier_uniqueness() {
        let a = generate_code_verifier();
        let b = generate_code_verifier();
        assert_ne!(a, b, "two verifiers should differ");
    }

    #[test]
    fn test_code_challenge_deterministic() {
        let v = generate_code_verifier();
        let c1 = derive_code_challenge(&v);
        let c2 = derive_code_challenge(&v);
        assert_eq!(c1, c2);
    }

    #[test]
    fn test_code_challenge_differs_from_verifier() {
        let v = generate_code_verifier();
        let c = derive_code_challenge(&v);
        assert_ne!(v, c);
    }

    #[test]
    fn test_code_challenge_is_base64url() {
        let v = generate_code_verifier();
        let c = derive_code_challenge(&v);
        assert!(c
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'));
    }

    #[test]
    fn test_known_pkce_vector() {
        // RFC 7636 Appendix B reference vector
        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
        let challenge = derive_code_challenge(verifier);
        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
    }

    #[test]
    fn test_oauth_provider_from_id() {
        assert_eq!(
            OAuthProvider::from_id("anthropic"),
            Some(OAuthProvider::Anthropic)
        );
        assert_eq!(
            OAuthProvider::from_id("openai"),
            Some(OAuthProvider::OpenAI)
        );
        assert_eq!(
            OAuthProvider::from_id("github"),
            Some(OAuthProvider::GitHub)
        );
        assert_eq!(
            OAuthProvider::from_id("github-copilot"),
            Some(OAuthProvider::GitHub)
        );
        assert_eq!(
            OAuthProvider::from_id("google"),
            Some(OAuthProvider::Google)
        );
        assert_eq!(OAuthProvider::from_id("azure"), Some(OAuthProvider::Azure));
        assert_eq!(OAuthProvider::from_id("unknown"), None);
    }

    #[test]
    fn test_oauth_provider_id_and_name() {
        let anthropic = OAuthProvider::Anthropic;
        assert_eq!(anthropic.id(), "anthropic");
        assert_eq!(anthropic.name(), "Anthropic");

        let openai = OAuthProvider::OpenAI;
        assert_eq!(openai.id(), "openai");
        assert_eq!(openai.name(), "OpenAI");

        let custom = OAuthProvider::Custom {
            id: "custom".into(),
            name: "Custom Provider".into(),
        };
        assert_eq!(custom.id(), "custom");
        assert_eq!(custom.name(), "Custom Provider");
    }

    #[test]
    fn test_oauth_provider_default_port() {
        assert_eq!(OAuthProvider::Anthropic.default_port(), 8787);
        assert_eq!(OAuthProvider::OpenAI.default_port(), 8788);
        assert_eq!(OAuthProvider::GitHub.default_port(), 8789);
        assert_eq!(OAuthProvider::Google.default_port(), 8790);
        assert_eq!(OAuthProvider::Azure.default_port(), 8791);
    }

    #[ignore] // broken test
    #[test]
    fn test_login_dialog_oauth_state() {
        let mut dialog = LoginDialog::new(vec!["anthropic".to_string()]);
        assert!(dialog.oauth_state.is_none());
        assert!(dialog.pending_auth_url.is_none());
        assert_eq!(dialog.login_state(), LoginState::ApiKey);

        // Start OAuth flow
        let url = dialog.start_oauth_flow(OAuthProvider::Anthropic).unwrap();
        assert!(url.contains("localhost:8787"));
        assert!(dialog.oauth_state.is_some());
        assert!(dialog.pending_auth_url.is_some());
        assert_eq!(dialog.login_state(), LoginState::WaitingForCallback);

        // Cancel OAuth
        dialog.cancel_oauth();
        assert!(dialog.oauth_state.is_none());
        assert!(dialog.pending_auth_url.is_none());
    }

    #[test]
    fn test_login_dialog_parse_redirect_url() {
        let url = "http://localhost:8787/callback?code=test_code_123&state=state_456";
        let result = LoginDialog::parse_redirect_url(url);
        assert!(result.is_some());
        let (code, state) = result.unwrap();
        assert_eq!(code, "test_code_123");
        assert_eq!(state, "state_456");
    }

    #[test]
    fn test_login_dialog_parse_redirect_url_simple() {
        let url = "?code=simple_code&state=state";
        let result = LoginDialog::parse_redirect_url(url);
        assert!(result.is_some());
        let (code, state) = result.unwrap();
        assert_eq!(code, "simple_code");
        assert_eq!(state, "state");
    }

    #[test]
    fn test_login_dialog_parse_redirect_url_invalid() {
        let url = "http://localhost:8787/callback?state=only_state";
        let result = LoginDialog::parse_redirect_url(url);
        assert!(result.is_none());
    }

    #[test]
    fn test_login_dialog_oauth_callback() {
        let mut dialog = LoginDialog::new(vec!["anthropic".to_string()]);
        dialog.start_oauth_flow(OAuthProvider::Anthropic).unwrap();

        let oauth_state = dialog.oauth_state.clone().unwrap();
        let result = dialog.handle_oauth_callback("auth_code".into(), oauth_state.state.clone());
        assert!(result.is_ok());
        assert_eq!(dialog.api_key, "auth_code");
    }

    #[test]
    fn test_login_dialog_oauth_callback_state_mismatch() {
        let mut dialog = LoginDialog::new(vec!["anthropic".to_string()]);
        dialog.start_oauth_flow(OAuthProvider::Anthropic).unwrap();

        let result = dialog.handle_oauth_callback("auth_code".into(), "wrong_state".into());
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("State mismatch"));
    }

    #[test]
    fn test_login_dialog_is_oauth_available() {
        let dialog = LoginDialog::new(vec![]);
        assert!(dialog.is_oauth_available("anthropic"));
        assert!(dialog.is_oauth_available("openai"));
        assert!(dialog.is_oauth_available("github"));
        assert!(dialog.is_oauth_available("github-copilot"));
        assert!(!dialog.is_oauth_available("unknown"));
    }

    #[test]
    fn test_login_dialog_complete_oauth() {
        let mut dialog = LoginDialog::new(vec!["anthropic".to_string()]);
        dialog.start_oauth_flow(OAuthProvider::Anthropic).unwrap();
        assert!(dialog.oauth_state.is_some());

        let result = dialog.complete_oauth("final_code".into());
        assert!(result.is_ok());
        assert_eq!(dialog.api_key, "final_code");
        assert!(dialog.oauth_state.is_none());
        assert!(dialog.pending_auth_url.is_none());
    }

    #[test]
    fn test_login_state_default() {
        assert_eq!(LoginState::default(), LoginState::ProviderSelection);
    }

    #[test]
    fn test_login_state_error() {
        let dialog = LoginDialog {
            providers: vec![],
            selected_provider_index: 0,
            api_key: String::new(),
            cursor_pos: 0,
            error_message: Some("test error".to_string()),
            is_masked: true,
            oauth_state: None,
            pending_auth_url: None,
        };
        assert_eq!(
            dialog.login_state(),
            LoginState::Error("test error".to_string())
        );
    }

    #[test]
    fn test_state_token_generation() {
        let state1 = generate_state_token();
        let state2 = generate_state_token();
        assert_ne!(state1, state2);
        assert!(state1.len() >= 16);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_session_selector_navigation() {
        let sessions = vec![
            SessionInfo {
                id: "1".to_string(),
                name: "Session 1".to_string(),
                created_at: "2025-01-01".to_string(),
                message_count: 5,
                model: Some("gpt-4".to_string()),
                parent_id: None,
            },
            SessionInfo {
                id: "2".to_string(),
                name: "Session 2".to_string(),
                created_at: "2025-01-02".to_string(),
                message_count: 3,
                model: Some("claude-3".to_string()),
                parent_id: Some("1".to_string()),
            },
        ];
        let mut selector = SessionSelector::new(sessions);
        assert_eq!(selector.selected().unwrap().id, "1");
        selector.move_down();
        assert_eq!(selector.selected().unwrap().id, "2");
        selector.move_up();
        assert_eq!(selector.selected().unwrap().id, "1");
    }

    #[test]
    fn test_session_selector_filter() {
        let sessions = vec![
            SessionInfo {
                id: "1".to_string(),
                name: "Rust coding".to_string(),
                created_at: "2025-01-01".to_string(),
                message_count: 5,
                model: None,
                parent_id: None,
            },
            SessionInfo {
                id: "2".to_string(),
                name: "Python coding".to_string(),
                created_at: "2025-01-02".to_string(),
                message_count: 3,
                model: None,
                parent_id: None,
            },
        ];
        let mut selector = SessionSelector::new(sessions);
        selector.set_filter("rust".to_string());
        let filtered = selector.filtered_sessions();
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].name, "Rust coding");
    }

    #[test]
    fn test_model_selector() {
        let models = vec![
            ModelInfo {
                id: "gpt-4o".to_string(),
                name: "GPT-4o".to_string(),
                provider: "openai".to_string(),
                supports_vision: true,
                supports_tools: true,
                supports_thinking: false,
                context_window: 128000,
            },
            ModelInfo {
                id: "claude-sonnet".to_string(),
                name: "Claude Sonnet".to_string(),
                provider: "anthropic".to_string(),
                supports_vision: true,
                supports_tools: true,
                supports_thinking: true,
                context_window: 200000,
            },
        ];
        let mut selector = ModelSelector::new(models);
        assert_eq!(selector.selected().unwrap().id, "claude-sonnet");
        selector.move_down();
        assert_eq!(selector.selected().unwrap().id, "gpt-4o");
    }

    #[test]
    fn test_footer_render() {
        let footer = FooterData {
            model_name: "gpt-4o".to_string(),
            session_name: "test".to_string(),
            provider_name: "openai".to_string(),
            input_tokens: 1000,
            output_tokens: 500,
            total_cost: 0.05,
            is_thinking: false,
            elapsed_seconds: Some(30),
        };
        let rendered = footer.render(80);
        assert!(rendered.contains("gpt-4o"));
        assert!(rendered.contains("openai"));
    }

    #[test]
    fn test_login_dialog() {
        let mut dialog = LoginDialog::new(vec!["anthropic".to_string(), "openai".to_string()]);
        assert_eq!(dialog.selected_provider(), Some("anthropic"));
        dialog.next_provider();
        assert_eq!(dialog.selected_provider(), Some("openai"));
        dialog.input_char('s');
        dialog.input_char('k');
        assert_eq!(dialog.api_key, "sk");
        dialog.backspace();
        assert_eq!(dialog.api_key, "s");
    }

    #[test]
    fn test_login_dialog_validation() {
        let mut dialog = LoginDialog::new(vec!["openai".to_string()]);
        assert!(dialog.validate().is_err()); // empty key
        dialog.api_key = "sk-1234".to_string();
        assert!(dialog.validate().is_ok());
    }

    #[test]
    fn test_diff_viewer() {
        let diff = "@@ -1,3 +1,3 @@\n line1\n-old line\n+new line\n line3\n";
        let viewer = DiffViewer::new("test.txt".to_string(), diff);
        assert_eq!(viewer.lines.len(), 5); // header + 4 lines
        let rendered = viewer.render();
        assert!(rendered.contains("old line"));
        assert!(rendered.contains("new line"));
    }

    #[test]
    fn test_diff_viewer_scroll() {
        let mut diff = "@@ -1,5 +1,5 @@\n".to_string();
        for i in 0..100 {
            diff.push_str(&format!(" line {}\n", i)); // context lines start with space
        }
        let mut viewer = DiffViewer::new("test.txt".to_string(), &diff);
        viewer.visible_height = 10;
        assert!(
            viewer.lines.len() > 10,
            "need {} lines, got {}",
            11,
            viewer.lines.len()
        );
        viewer.scroll_down(10);
        assert!(viewer.scroll_offset > 0);
        viewer.scroll_up(5);
        assert!(viewer.scroll_offset < 10);
    }

    #[test]
    fn test_bash_execution() {
        let mut exec = BashExecution::new("echo hello".to_string());
        assert!(exec.is_running);
        exec.append_output("hello\n");
        exec.complete(0);
        assert!(!exec.is_running);
        assert_eq!(exec.exit_code, Some(0));
        let rendered = exec.render();
        assert!(rendered.contains("echo hello"));
        assert!(rendered.contains("hello"));
        assert!(rendered.contains("Done"));
    }

    #[test]
    fn test_bash_execution_cancel() {
        let mut exec = BashExecution::new("sleep 999".to_string());
        exec.cancel();
        assert!(exec.is_cancelled);
        assert!(!exec.is_running);
        let rendered = exec.render();
        assert!(rendered.contains("CANCELLED"));
    }

    #[test]
    fn test_parse_hunk_header() {
        let result = parse_hunk_header("@@ -1,3 +1,3 @@");
        assert_eq!(result, Some((1, 3, 1, 3)));
    }

    #[test]
    fn test_format_bytes() {
        assert_eq!(format_bytes(500), "500B");
        assert_eq!(format_bytes(1024), "1.0KB");
        assert_eq!(format_bytes(1024 * 1024), "1.0MB");
        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0GB");
    }

    // ── Assistant Message Tests ────────────────────────────────────────────────

    #[test]
    fn test_assistant_message_builder() {
        let mut msg = AssistantMessage::new();
        msg.add_text("Hello, world!");
        msg.add_thinking("Let me think about this...");
        msg.add_tool_call("call_123", "bash", r#"{"command": "ls"}"#);

        assert!(msg.has_visible_content());
        assert!(msg.has_tool_calls());
        assert_eq!(msg.content.len(), 3);
    }

    #[test]
    fn test_assistant_message_renderer_hide_thinking() {
        let mut msg = AssistantMessage::new();
        msg.add_thinking("This is my thought process");
        msg.add_text("Final answer");

        let renderer = AssistantMessageRenderer::new(AssistantMessageRenderOptions {
            hide_thinking: true,
            hidden_thinking_label: "Thinking...".to_string(),
            use_osc133: false,
        });

        let rendered = renderer.render(&msg);
        assert!(rendered.contains("Thinking..."));
        assert!(rendered.contains("Final answer"));
        assert!(!rendered.contains("This is my thought process"));
    }

    #[test]
    fn test_assistant_message_renderer_show_thinking() {
        let mut msg = AssistantMessage::new();
        msg.add_thinking("This is my thought process");

        let renderer = AssistantMessageRenderer::new(AssistantMessageRenderOptions::default());
        let rendered = renderer.render(&msg);
        assert!(rendered.contains("This is my thought process"));
    }

    #[test]
    fn test_assistant_message_renderer_error() {
        let mut msg = AssistantMessage::new();
        msg.add_text("Some content");
        msg.stop_reason = Some(StopReason::Error);
        msg.error_message = Some("Something went wrong".to_string());

        let renderer = AssistantMessageRenderer::new(AssistantMessageRenderOptions::default());
        let rendered = renderer.render(&msg);
        assert!(rendered.contains("Error: Something went wrong"));
    }

    #[test]
    fn test_assistant_message_renderer_aborted() {
        let mut msg = AssistantMessage::new();
        msg.stop_reason = Some(StopReason::Aborted);

        let renderer = AssistantMessageRenderer::new(AssistantMessageRenderOptions::default());
        let rendered = renderer.render(&msg);
        assert!(rendered.contains("Operation aborted"));
    }

    #[test]
    fn test_stop_reason_as_str() {
        assert_eq!(StopReason::EndTurn.as_str(), "end_turn");
        assert_eq!(StopReason::MaxTokens.as_str(), "max_tokens");
        assert_eq!(StopReason::StopSequence.as_str(), "stop_sequence");
        assert_eq!(StopReason::Aborted.as_str(), "aborted");
        assert_eq!(StopReason::Error.as_str(), "error");
    }

    #[test]
    fn test_render_markdown() {
        // Test inline code
        let result = render_markdown("Use `ls` to list files");
        assert!(result.contains("\x1b[33m")); // Yellow for code

        // Test bold
        let result = render_markdown("This is **bold** text");
        assert!(result.contains("\x1b[1m")); // Bold

        // Test italic
        let result = render_markdown("This is _italic_ text");
        assert!(result.contains("\x1b[3m")); // Italic
    }

    // ── Tool Execution Tests ─────────────────────────────────────────────────

    #[test]
    fn test_tool_result_text() {
        let result = ToolResult::new_text("file created successfully");
        assert!(!result.is_error);
        assert_eq!(
            result.get_text(),
            Some("file created successfully".to_string())
        );
    }

    #[test]
    fn test_tool_result_error() {
        let result = ToolResult::error("file not found");
        assert!(result.is_error);
        assert_eq!(result.get_text(), Some("file not found".to_string()));
    }

    #[test]
    fn test_tool_result_images() {
        let mut result = ToolResult::new_text("analysis complete");
        result.content.push(ToolContentBlock::Image {
            data: "base64data".to_string(),
            mime_type: "image/png".to_string(),
        });

        assert!(result.has_images());
        assert_eq!(result.image_count(), 1);
        assert!(result.get_text().is_some());
    }

    #[test]
    fn test_tool_execution_pending() {
        let exec = ToolExecution::new(
            "read_file",
            "call_abc",
            serde_json::json!({"path": "test.txt"}),
        );

        assert_eq!(exec.state, ToolExecutionState::Pending);
        assert!(exec.result.is_none());
    }

    #[test]
    fn test_tool_execution_complete() {
        let mut exec = ToolExecution::new("bash", "call_123", serde_json::json!({"command": "ls"}));
        exec.start();
        assert_eq!(exec.state, ToolExecutionState::Running);

        exec.complete(ToolResult::new_text("file1.txt\nfile2.txt"));
        assert_eq!(exec.state, ToolExecutionState::Success);
        assert!(exec.result.is_some());
    }

    #[test]
    fn test_tool_execution_error() {
        let mut exec = ToolExecution::new("bash", "call_123", serde_json::json!({"command": "ls"}));
        exec.complete(ToolResult::error("Permission denied"));
        assert_eq!(exec.state, ToolExecutionState::Error);
    }

    #[test]
    fn test_tool_execution_format_arguments() {
        let exec = ToolExecution::new(
            "search",
            "call_1",
            serde_json::json!({"query": "test", "limit": 10}),
        );
        let args = exec.format_arguments();
        assert!(args.contains("test"));
        assert!(args.contains("10"));
    }

    #[test]
    fn test_tool_execution_render() {
        let mut exec = ToolExecution::new(
            "read_file",
            "call_1",
            serde_json::json!({"path": "test.txt"}),
        );
        exec.complete(ToolResult::new_text("file contents"));

        let rendered = exec.render();
        assert!(rendered.contains("read_file"));
        assert!(rendered.contains("file contents"));
    }

    #[test]
    fn test_truncate_text() {
        let long_text = "This is a very long text that should be truncated. ".repeat(20);
        let truncated = truncate_text(&long_text, 100);
        assert!(truncated.len() < long_text.len());
        assert!(truncated.ends_with("..."));
    }

    // ── Bash Execution Tests ─────────────────────────────────────────────────

    #[test]
    fn test_bash_execution_expanded() {
        let mut exec = BashExecution::new("echo test".to_string());
        exec.append_output("line1\nline2\nline3\n");
        exec.set_expanded(true);

        assert!(exec.expanded);
        let rendered = exec.render();
        assert!(rendered.contains("line1"));
    }

    #[test]
    fn test_bash_execution_preview() {
        let mut exec = BashExecution::new("ls -la".to_string());
        // Add many lines
        for i in 0..50 {
            exec.append_output(&format!("line{}\n", i));
        }

        let rendered = exec.render();
        // Should show hidden lines message
        assert!(rendered.contains("more lines"));
    }

    #[test]
    fn test_bash_execution_strip_ansi() {
        let input = "\x1b[31mRed text\x1b[0m and normal";
        let stripped = strip_ansi(input);
        assert_eq!(stripped, "Red text and normal");
    }

    #[test]
    fn test_bash_execution_truncation() {
        let mut exec = BashExecution::new("cat large_file.txt".to_string());
        exec.append_output("content");
        exec.complete_with_truncation(
            0,
            TruncationInfo {
                total_lines: 1000,
                shown_lines: 500,
                total_bytes: 50000,
            },
            Some("/tmp/full_output.txt".to_string()),
        );

        let rendered = exec.render();
        assert!(rendered.contains("truncated"));
        assert!(rendered.contains("/tmp/full_output.txt"));
    }

    #[test]
    fn test_bash_execution_get_output() {
        let mut exec = BashExecution::new("echo hello".to_string());
        exec.append_output("hello world");
        assert_eq!(exec.get_output(), "hello world");
    }

    #[test]
    fn test_bash_execution_output_lines() {
        let mut exec = BashExecution::new("echo hello".to_string());
        exec.append_output("line1\nline2\nline3");
        let lines = exec.output_lines();
        assert_eq!(lines, vec!["line1", "line2", "line3"]);
    }

    // ── Summary Message Tests ────────────────────────────────────────────────

    #[test]
    fn test_summary_message_compaction() {
        let mut msg = SummaryMessage::compaction(50000, "Compacted 50000 tokens to 10000");
        assert!(matches!(
            msg.message_type,
            SummaryMessageType::Compaction {
                tokens_before: 50000
            }
        ));

        msg.set_expanded(true);
        let rendered = msg.render();
        assert!(rendered.contains("compaction"));
        assert!(rendered.contains("Compacted from 50000 tokens"));
    }

    #[test]
    fn test_summary_message_branch() {
        let mut msg = SummaryMessage::branch("Created a new branch with these changes...");
        assert!(matches!(msg.message_type, SummaryMessageType::Branch));

        msg.set_expanded(true);
        let rendered = msg.render();
        assert!(rendered.contains("[branch]"));
    }

    #[test]
    fn test_summary_message_renderer() {
        let rendered =
            SummaryMessageRenderer::render_compaction(50000, "Summary of compacted content", true);
        assert!(rendered.contains("50000"));
    }

    // ── Armin Component Tests ──────────────────────────────────────────────────

    #[test]
    fn test_armin_component_render() {
        let mut armin = ArminComponent::new();
        let lines = armin.render(40);
        assert!(!lines.is_empty());
        // Should have DISPLAY_HEIGHT lines + 1 for "ARMIN SAYS HI"
        assert_eq!(lines.len(), ARMIN_DISPLAY_HEIGHT + 1);
        // Last line should contain "ARMIN SAYS HI"
        let last = lines.last().unwrap();
        let visible = strip_ansi(last);
        assert!(visible.contains("ARMIN SAYS HI"));
    }

    #[test]
    fn test_armin_component_caching() {
        let mut armin = ArminComponent::new();
        let lines1 = armin.render(80);
        let lines2 = armin.render(80);
        assert_eq!(lines1, lines2);
    }

    #[test]
    fn test_armin_pixel_and_char() {
        // Verify pixel function works for a few positions
        let _ = armin_get_pixel(0, 0);
        let _ = armin_get_pixel(30, 35);
        // Out-of-bounds y should be false (background)
        assert!(!armin_get_pixel(0, 100));
        // Char function should return valid block characters
        let ch = armin_get_char(0, 0);
        assert!("█▀▄ ".contains(ch));
    }

    // ── Daxnuts Component Tests ────────────────────────────────────────────────

    #[test]
    fn test_daxnuts_component_render() {
        let dax = DaxnutsComponent::new();
        let lines = dax.render(80);
        assert!(!lines.is_empty());
        // Should contain the image lines, plus text
        let joined = lines.join("\n");
        let visible = strip_ansi(&joined);
        assert!(visible.contains("Powered by daxnuts"));
        assert!(visible.contains("@thdxr"));
    }

    #[test]
    fn test_daxnuts_image_builds() {
        let image = build_dax_image();
        // 32 pixels tall / 2 = 16 half-block rows
        assert_eq!(image.len(), 16);
        // Each line should contain ANSI escape codes and ▄ characters
        for line in &image {
            assert!(line.contains('\x1b'));
        }
    }

    // ── Dynamic Border Tests ──────────────────────────────────────────────────

    #[test]
    fn test_dynamic_border_default() {
        let border = DynamicBorder::new();
        let lines = border.render(40);
        assert_eq!(lines.len(), 1);
        let visible = strip_ansi(&lines[0]);
        assert_eq!(visible.chars().count(), 40);
        assert!(visible.chars().all(|c| c == '─'));
    }

    #[test]
    fn test_dynamic_border_accent() {
        let border = DynamicBorder::accent();
        let lines = border.render(20);
        assert_eq!(lines.len(), 1);
        let visible = strip_ansi(&lines[0]);
        assert_eq!(visible.chars().count(), 20);
        // Should contain accent color code
        assert!(lines[0].contains("\x1b[38;5;75m"));
    }

    #[test]
    fn test_dynamic_border_custom_color() {
        let border = DynamicBorder::with_style(BorderStyle::Custom(200));
        let lines = border.render(10);
        assert!(lines[0].contains("38;5;200"));
    }

    #[test]
    fn test_dynamic_border_min_width() {
        let border = DynamicBorder::new();
        let lines = border.render(0);
        assert_eq!(lines.len(), 1);
        let visible = strip_ansi(&lines[0]);
        assert_eq!(visible.chars().count(), 1); // max(1, 0)
    }

    // ── Earendil Announcement Tests ───────────────────────────────────────────

    #[test]
    fn test_earendil_announcement_basic() {
        let ann = EarendilAnnouncement::new("Hello World");
        let lines = ann.render(60);
        assert!(!lines.is_empty());
        let joined = lines.join("\n");
        let visible = strip_ansi(&joined);
        assert!(visible.contains("Hello World"));
        // Should have top and bottom borders
        assert!(joined.contains('─'));
    }

    #[test]
    fn test_earendil_announcement_with_body() {
        let mut ann = EarendilAnnouncement::new("Title");
        ann.add_body("First line");
        ann.add_body("https://example.com");
        let lines = ann.render(60);
        let joined = lines.join("\n");
        let visible = strip_ansi(&joined);
        assert!(visible.contains("First line"));
        assert!(visible.contains("https://example.com"));
    }

    #[test]
    fn test_earendil_announcement_default() {
        let ann = EarendilAnnouncement::earendil_default();
        let lines = ann.render(80);
        let joined = lines.join("\n");
        let visible = strip_ansi(&joined);
        assert!(visible.contains("pi has joined Earendil"));
        assert!(visible.contains("mariozechner.at"));
    }

    // ── Tool Execution Display Tests ──────────────────────────────────────────

    #[test]
    fn test_tool_execution_display_pending() {
        let display = ToolExecutionDisplay::new(
            "read_file",
            "call_abc",
            serde_json::json!({"path": "test.txt"}),
        );
        assert_eq!(display.state, ToolExecutionState::Pending);
        assert!(display.started_at.is_none());
    }

    #[test]
    fn test_tool_execution_display_lifecycle() {
        let mut display =
            ToolExecutionDisplay::new("bash", "call_123", serde_json::json!({"command": "ls"}));
        display.start();
        assert_eq!(display.state, ToolExecutionState::Running);
        assert!(display.started_at.is_some());

        display.complete(ToolResult::new_text("file1.txt\nfile2.txt"));
        assert_eq!(display.state, ToolExecutionState::Success);
        assert!(display.completed_at.is_some());
    }

    #[test]
    fn test_tool_execution_display_error() {
        let mut display = ToolExecutionDisplay::new(
            "bash",
            "call_err",
            serde_json::json!({"command": "rm -rf /"}),
        );
        display.start();
        display.complete(ToolResult::error("Permission denied"));
        assert_eq!(display.state, ToolExecutionState::Error);
    }

    #[test]
    fn test_tool_execution_display_render() {
        let mut display = ToolExecutionDisplay::new(
            "read_file",
            "call_1",
            serde_json::json!({"path": "test.txt"}),
        );
        display.start();
        display.complete(ToolResult::new_text("file contents"));

        let lines = display.render();
        let joined = lines.join("\n");
        let visible = strip_ansi(&joined);
        assert!(visible.contains("read_file"));
        assert!(visible.contains("file contents"));
    }

    #[test]
    fn test_tool_execution_display_expanded() {
        let mut display =
            ToolExecutionDisplay::new("search", "call_2", serde_json::json!({"query": "test"}));
        display.start();

        let long_output: String = "result line\n".repeat(100);
        display.complete(ToolResult::new_text(long_output));

        // Collapsed: should have truncation hint
        let lines_collapsed = display.render();
        let joined = lines_collapsed.join("\n");
        assert!(joined.contains("expand"));

        // Expanded: should show full output
        display.set_expanded(true);
        let lines_expanded = display.render();
        assert!(lines_expanded.len() > lines_collapsed.len());
    }

    #[test]
    fn test_tool_execution_display_elapsed() {
        let mut display = ToolExecutionDisplay::new("test", "call", serde_json::json!(null));
        // Not started - no elapsed
        assert!(display.elapsed_str().is_empty());

        display.start();
        // Running - should have elapsed time
        let elapsed = display.elapsed_str();
        assert!(!elapsed.is_empty());
    }

    #[test]
    fn test_tool_execution_display_args_streaming() {
        let mut display = ToolExecutionDisplay::new("bash", "call_stream", serde_json::json!(null));
        assert!(display.is_partial);

        display.update_args(serde_json::json!({"command": "ls"}));
        assert_eq!(display.arguments["command"], "ls");

        display.set_args_complete();
        assert!(!display.is_partial);
        assert!(display.args_complete);
    }

    #[test]
    fn test_tool_execution_display_toggle() {
        let mut display = ToolExecutionDisplay::new("test", "call", serde_json::json!(null));
        assert!(!display.expanded);
        display.toggle_expanded();
        assert!(display.expanded);
        display.toggle_expanded();
        assert!(!display.expanded);
    }

    #[test]
    fn test_center_ansi() {
        let result = center_ansi("hello", 20);
        let visible = strip_ansi(&result);
        // left padding = (20 - 5) / 2 = 7, so "       hello" = 12 chars
        assert_eq!(visible.chars().count(), 12);
        assert!(visible.starts_with("       hello"));
    }

    #[test]
    fn test_center_ansi_with_codes() {
        let result = center_ansi("\x1b[31mhello\x1b[0m", 20);
        let visible = strip_ansi(&result);
        // ANSI codes stripped, visible = "       hello" = 12 chars
        assert_eq!(visible.chars().count(), 12);
        assert!(visible.contains("hello"));
    }

    // ── Message Component Tests ──────────────────────────────────────────────

    #[test]
    fn test_user_message_renderer() {
        let renderer = UserMessageRenderer::new("Hello, world!");
        let lines = renderer.render(80);
        assert!(!lines.is_empty());
        let joined = lines.join("");
        assert!(joined.contains("Hello, world!"));
    }

    #[test]
    fn test_user_message_renderer_with_images() {
        let renderer = UserMessageRenderer::new("Check this out").with_images(true);
        let lines = renderer.render(80);
        let joined = lines.join("\n");
        assert!(joined.contains("has images"));
    }

    #[test]
    fn test_user_message_renderer_osc133() {
        let renderer = UserMessageRenderer::new("test").with_osc133(true);
        let lines = renderer.render(80);
        assert!(!lines.is_empty());
        assert!(lines[0].contains("\x1b]133;A\x07"));
        let last = lines.last().unwrap();
        assert!(last.contains("\x1b]133;B\x07"));
    }

    #[test]
    fn test_user_message_selector() {
        let messages = vec![
            UserMessageItem {
                id: "1".to_string(),
                text: "First message".to_string(),
                timestamp: None,
            },
            UserMessageItem {
                id: "2".to_string(),
                text: "Second message".to_string(),
                timestamp: None,
            },
            UserMessageItem {
                id: "3".to_string(),
                text: "Third message".to_string(),
                timestamp: None,
            },
        ];
        let mut selector = UserMessageSelector::new(messages);

        // Default: last message selected
        assert_eq!(selector.selected().unwrap().id, "3");

        // Navigate up (older)
        selector.move_up();
        assert_eq!(selector.selected().unwrap().id, "2");
        selector.move_up();
        assert_eq!(selector.selected().unwrap().id, "1");

        // Wrap to bottom
        selector.move_up();
        assert_eq!(selector.selected().unwrap().id, "3");

        // Navigate down
        selector.move_down();
        assert_eq!(selector.selected().unwrap().id, "1");

        // Continue down
        selector.move_down();
        assert_eq!(selector.selected().unwrap().id, "2");

        // Wrap to top
        selector.move_down();
        assert_eq!(selector.selected().unwrap().id, "3");
    }

    #[test]
    fn test_user_message_selector_with_initial_id() {
        let messages = vec![
            UserMessageItem {
                id: "a".to_string(),
                text: "msg a".to_string(),
                timestamp: None,
            },
            UserMessageItem {
                id: "b".to_string(),
                text: "msg b".to_string(),
                timestamp: None,
            },
        ];
        let selector = UserMessageSelector::new(messages).with_initial_id("b");
        assert_eq!(selector.selected().unwrap().id, "b");
    }

    #[test]
    fn test_user_message_selector_render() {
        let messages = vec![UserMessageItem {
            id: "1".to_string(),
            text: "Hello".to_string(),
            timestamp: None,
        }];
        let selector = UserMessageSelector::new(messages);
        let lines = selector.render(80);
        let joined = lines.join("\n");
        assert!(joined.contains("Fork from Message"));
        assert!(joined.contains("Hello"));
    }

    #[test]
    fn test_user_message_selector_empty() {
        let selector = UserMessageSelector::new(vec![]);
        let lines = selector.render(80);
        let joined = lines.join("\n");
        assert!(joined.contains("No user messages found"));
    }

    #[test]
    fn test_skill_invocation_message() {
        let skill = SkillInvocationMessage::new("my_skill", "arg1 arg2");
        let lines = skill.render(80);
        assert_eq!(lines.len(), 1);
        assert!(lines[0].contains("my_skill"));
        assert!(lines[0].contains("expand"));
    }

    #[test]
    fn test_skill_invocation_message_expanded() {
        let mut skill = SkillInvocationMessage::new("my_skill", "This is the content");
        skill.set_expanded(true);
        let lines = skill.render(80);
        assert!(lines.len() >= 2);
        let joined = lines.join("\n");
        assert!(joined.contains("my_skill"));
        assert!(joined.contains("This is the content"));
    }

    #[test]
    fn test_diff_renderer_basic() {
        let renderer = DiffRenderer::new();
        let diff = " 1 old line\n-2 removed\n+3 added\n 4 context\n";
        let result = renderer.render(diff, None);
        assert!(result.contains("removed"));
        assert!(result.contains("added"));
        assert!(result.contains("context"));
    }

    #[test]
    fn test_diff_renderer_colors() {
        let renderer = DiffRenderer::new();
        let diff = "-1 removed line\n+2 added line\n";
        let result = renderer.render(diff, None);
        // Should contain red for removed
        assert!(result.contains("\x1b[31m"));
        // Should contain green for added
        assert!(result.contains("\x1b[32m"));
    }

    #[test]
    fn test_diff_renderer_no_word_diff() {
        let renderer = DiffRenderer::new_simple();
        let diff = "-1 foo bar\n+2 foo baz\n";
        let result = renderer.render(diff, None);
        assert!(result.contains("foo"));
    }

    #[test]
    fn test_diff_renderer_hunk_header() {
        let renderer = DiffRenderer::new();
        let diff = "@@ -1,3 +1,3 @@\n context\n";
        let result = renderer.render(diff, None);
        // Hunk headers should be colored cyan
        assert!(result.contains("@@"));
    }

    #[test]
    fn test_keybinding_hints_render() {
        let hints = vec![
            KeyHint::new("Ctrl+C", "Cancel"),
            KeyHint::new("Enter", "Confirm"),
        ];
        let lines = KeybindingHints::render(&hints);
        assert_eq!(lines.len(), 2);
        assert!(lines[0].contains("Ctrl+C"));
        assert!(lines[1].contains("Enter"));
    }

    #[test]
    fn test_keybinding_hints_inline() {
        let hints = vec![
            KeyHint::new("↑↓", "Navigate"),
            KeyHint::new("Enter", "Select"),
        ];
        let inline = KeybindingHints::render_inline(&hints);
        assert!(inline.contains("Navigate"));
        assert!(inline.contains("Select"));
    }

    #[test]
    fn test_footer_component_data_render() {
        let mut data = FooterComponentData::default();
        data.model_name = "claude-sonnet-4".to_string();
        data.provider_name = "anthropic".to_string();
        data.pwd = Some("~/projects/oxi".to_string());
        data.git_branch = Some("main".to_string());
        data.session_name = Some("test-session".to_string());
        data.input_tokens = 5000;
        data.output_tokens = 2000;
        data.total_cost = 0.035;
        data.context_window_pct = 45.0;
        data.context_window = 200000;

        let lines = data.render(120);
        assert!(lines.len() >= 2);

        let pwd_line = &lines[0];
        assert!(pwd_line.contains("~/projects/oxi"));
        assert!(pwd_line.contains("main"));
        assert!(pwd_line.contains("test-session"));

        let stats_line = &lines[1];
        assert!(stats_line.contains("claude-sonnet-4"));
    }

    #[test]
    fn test_footer_component_data_token_formatting() {
        assert_eq!(FooterComponentData::format_token_count(500), "500");
        assert_eq!(FooterComponentData::format_token_count(1500), "1.5k");
        assert_eq!(FooterComponentData::format_token_count(15000), "15k");
        assert_eq!(FooterComponentData::format_token_count(1_500_000), "1.5M");
        assert_eq!(FooterComponentData::format_token_count(15_000_000), "15M");
    }

    #[test]
    fn test_footer_component_context_coloring() {
        let mut data = FooterComponentData::default();
        data.context_window_pct = 95.0;
        data.context_window = 200000;
        let lines = data.render(120);
        // High usage should be red
        assert!(lines[1].contains("\x1b["));

        data.context_window_pct = 75.0;
        let lines2 = data.render(120);
        // Warning level
        assert!(lines2[1].contains("\x1b["));

        data.context_window_pct = 30.0;
        let lines3 = data.render(120);
        // Normal level - no special color
        assert!(lines3[1].contains("30.0%"));
    }

    #[test]
    fn test_visual_truncate_short() {
        let result = VisualTruncate::truncate("hello\nworld", 10, 80, 0);
        assert_eq!(result.visual_lines.len(), 2);
        assert_eq!(result.skipped_count, 0);
    }

    #[test]
    fn test_visual_truncate_long() {
        let text: String = (0..20)
            .map(|i| format!("line {}", i))
            .collect::<Vec<_>>()
            .join("\n");
        let result = VisualTruncate::truncate(&text, 5, 80, 0);
        assert_eq!(result.visual_lines.len(), 5);
        assert_eq!(result.skipped_count, 15);
        // Should show last 5 lines
        assert!(result.visual_lines[0].contains("line 15"));
    }

    #[test]
    fn test_visual_truncate_wrapping() {
        // Line longer than width should wrap
        let text = "abcdefghij"; // 10 chars
        let result = VisualTruncate::truncate(text, 10, 5, 0);
        // Should wrap into 2 lines
        assert_eq!(result.visual_lines.len(), 2);
        assert_eq!(result.visual_lines[0], "abcde");
        assert_eq!(result.visual_lines[1], "fghij");
    }

    #[test]
    fn test_visual_truncate_empty() {
        let result = VisualTruncate::truncate("", 10, 80, 0);
        assert!(result.visual_lines.is_empty());
        assert_eq!(result.skipped_count, 0);
    }

    #[test]
    fn test_show_images_selector() {
        let mut selector = ShowImagesSelector::new(true);
        assert!(selector.confirm()); // default "yes"

        selector.move_down();
        assert!(!selector.confirm()); // "no"

        selector.move_up();
        assert!(selector.confirm()); // back to "yes"
    }

    #[test]
    fn test_show_images_selector_render() {
        let selector = ShowImagesSelector::new(false);
        let lines = selector.render(60);
        let joined = lines.join("\n");
        assert!(joined.contains("Yes"));
        assert!(joined.contains("No"));
        assert!(joined.contains("Show images"));
    }

    #[test]
    fn test_countdown_timer() {
        let mut timer = CountdownTimer::new(10);
        assert_eq!(timer.remaining_seconds, 10);
        assert!(!timer.is_expired());

        // Tick 9 times
        for _ in 0..9 {
            assert!(!timer.tick());
        }
        assert_eq!(timer.remaining_seconds, 1);
        assert!(!timer.is_expired());

        // Final tick
        assert!(timer.tick());
        assert!(timer.is_expired());
    }

    #[test]
    fn test_countdown_timer_from_millis() {
        let timer = CountdownTimer::from_millis(5500);
        assert_eq!(timer.remaining_seconds, 6); // ceil(5500/1000) = 6
    }

    #[test]
    fn test_countdown_timer_render() {
        let timer = CountdownTimer::new(30);
        let rendered = timer.render();
        assert!(rendered.contains("30s"));

        let timer_urgent = CountdownTimer::new(3);
        let rendered_urgent = timer_urgent.render();
        assert!(rendered_urgent.contains("3s"));

        let timer_expired = CountdownTimer::new(0);
        let rendered_expired = timer_expired.render();
        assert!(rendered_expired.contains("Expired"));
    }

    #[test]
    fn test_countdown_timer_bar() {
        let timer = CountdownTimer::new(10);
        let bar = timer.render_bar(20);
        assert!(!bar.is_empty());
        assert!(bar.contains('█'));
        assert!(bar.contains('░'));
    }

    #[test]
    fn test_truncate_str() {
        assert_eq!(truncate_str("hello", 10), "hello");
        assert_eq!(truncate_str("hello world", 8), "hello...");
    }

    #[test]
    fn test_visible_len() {
        assert_eq!(visible_len("hello"), 5);
        assert_eq!(visible_len("\x1b[31mred\x1b[0m"), 3); // "red"
        assert_eq!(visible_len("\x1b]133;A\x07test"), 4); // "test"
    }

    // ── Extension Component Tests ──────────────────────────────────────────

    #[test]
    fn test_extension_editor_new() {
        let editor = ExtensionEditor::new("My Title", Some("prefill text"));
        assert_eq!(editor.title, "My Title");
        assert_eq!(editor.text, "prefill text");
        assert_eq!(editor.cursor_pos, "prefill text".len());
    }

    #[test]
    fn test_extension_editor_input() {
        let mut editor = ExtensionEditor::new("Title", None);
        assert!(editor.text.is_empty());
        editor.input_char('h');
        editor.input_char('i');
        assert_eq!(editor.text, "hi");
        editor.backspace();
        assert_eq!(editor.text, "h");
    }

    #[test]
    fn test_extension_editor_set_text() {
        let mut editor = ExtensionEditor::new("Title", Some("old"));
        editor.set_text("new text");
        assert_eq!(editor.text, "new text");
        assert_eq!(editor.cursor_pos, 8);
    }

    #[test]
    fn test_extension_editor_render() {
        let editor = ExtensionEditor::new("My Editor", Some("hello"));
        let lines = editor.render();
        assert!(!lines.is_empty());
        assert!(lines.iter().any(|l| l.contains("My Editor")));
        assert!(lines.iter().any(|l| l.contains("hello")));
    }

    #[test]
    fn test_extension_input_new() {
        let input = ExtensionInput::new("Enter value:", Some(30));
        assert_eq!(input.title, "Enter value:");
        assert_eq!(input.timeout_secs, Some(30));
        assert_eq!(input.remaining_secs, Some(30));
    }

    #[test]
    fn test_extension_input_typing() {
        let mut input = ExtensionInput::new("Prompt", None);
        input.input_char('a');
        input.input_char('b');
        assert_eq!(input.value, "ab");
        input.backspace();
        assert_eq!(input.value, "a");
    }

    #[test]
    fn test_extension_input_tick() {
        let mut input = ExtensionInput::new("Title", Some(3));
        assert!(input.tick()); // 3 -> 2
        assert_eq!(input.remaining_secs, Some(2));
        assert!(input.tick()); // 2 -> 1
        assert_eq!(input.remaining_secs, Some(1));
        assert!(!input.tick()); // 1 -> 0, returns false (expired)
        assert_eq!(input.remaining_secs, Some(0));
        assert!(!input.tick()); // already 0
    }

    #[test]
    fn test_extension_input_render() {
        let input = ExtensionInput::new("Name:", None);
        let lines = input.render();
        assert!(lines.iter().any(|l| l.contains("Name:")));
    }

    #[test]
    fn test_extension_input_render_countdown() {
        let input = ExtensionInput::new("Hurry:", Some(10));
        let lines = input.render();
        assert!(lines.iter().any(|l| l.contains("10s")));
    }

    #[test]
    fn test_extension_selector_navigation() {
        let mut sel = ExtensionSelector::new(
            "Pick",
            vec!["A".to_string(), "B".to_string(), "C".to_string()],
            None,
        );
        assert_eq!(sel.selected(), Some("A"));
        sel.move_down();
        assert_eq!(sel.selected(), Some("B"));
        sel.move_down();
        assert_eq!(sel.selected(), Some("C"));
        sel.move_down(); // clamped
        assert_eq!(sel.selected(), Some("C"));
        sel.move_up();
        assert_eq!(sel.selected(), Some("B"));
        sel.move_up();
        assert_eq!(sel.selected(), Some("A"));
        sel.move_up(); // clamped
        assert_eq!(sel.selected(), Some("A"));
    }

    #[test]
    fn test_extension_selector_render() {
        let sel = ExtensionSelector::new("Choose", vec!["X".to_string(), "Y".to_string()], None);
        let lines = sel.render();
        assert!(lines.iter().any(|l| l.contains("Choose")));
        assert!(lines.iter().any(|l| l.contains("X")));
        assert!(lines.iter().any(|l| l.contains("Y")));
    }

    #[test]
    fn test_custom_editor() {
        let mut editor = CustomEditor::new();
        assert!(editor.is_empty());
        editor.input_char('x');
        assert!(!editor.is_empty());
        assert_eq!(editor.text, "x");
        editor.backspace();
        assert!(editor.is_empty());
    }

    #[test]
    fn test_custom_editor_with_text() {
        let editor = CustomEditor::with_text("hello world");
        assert_eq!(editor.text, "hello world");
        assert_eq!(editor.cursor_pos, 11);
    }

    #[test]
    fn test_custom_editor_register_action() {
        let mut editor = CustomEditor::new();
        editor.register_action("app.interrupt");
        editor.register_action("app.exit");
        editor.register_action("app.interrupt"); // dup
        assert_eq!(editor.registered_actions.len(), 2);
    }

    #[test]
    fn test_custom_message_component() {
        let msg = CustomMessageComponent::new("deploy", "Deployed to production");
        assert_eq!(msg.custom_type, "deploy");
        assert!(!msg.expanded);
    }

    #[test]
    fn test_custom_message_expand() {
        let mut msg = CustomMessageComponent::new("test", "content");
        assert!(!msg.expanded);
        msg.toggle_expanded();
        assert!(msg.expanded);
        msg.set_expanded(false);
        assert!(!msg.expanded);
    }

    #[test]
    fn test_custom_message_render_collapsed() {
        let msg = CustomMessageComponent::new("deploy", "Short text");
        let lines = msg.render();
        assert!(lines.iter().any(|l| l.contains("[deploy]")));
        assert!(lines.iter().any(|l| l.contains("Short text")));
    }

    #[test]
    fn test_custom_message_render_expanded() {
        let mut msg = CustomMessageComponent::new("deploy", "line1\nline2\nline3");
        msg.set_expanded(true);
        let lines = msg.render();
        assert!(lines.iter().any(|l| l.contains("line1")));
        assert!(lines.iter().any(|l| l.contains("line2")));
        assert!(lines.iter().any(|l| l.contains("line3")));
    }

    #[test]
    fn test_provider_login_dialog_new() {
        let dialog = ProviderLoginDialog::new("anthropic", "Anthropic");
        assert_eq!(dialog.provider_id, "anthropic");
        assert!(matches!(dialog.phase, LoginDialogPhase::Init));
    }

    #[test]
    fn test_provider_login_dialog_with_title() {
        let dialog = ProviderLoginDialog::new("openai", "OpenAI").with_title("Custom Login");
        assert_eq!(dialog.title, Some("Custom Login".to_string()));
    }

    #[test]
    fn test_provider_login_dialog_show_auth() {
        let mut dialog = ProviderLoginDialog::new("github", "GitHub");
        dialog.show_auth(
            "https://github.com/login",
            Some("Follow instructions".to_string()),
        );
        match &dialog.phase {
            LoginDialogPhase::ShowAuth { url, instructions } => {
                assert_eq!(url, "https://github.com/login");
                assert_eq!(instructions, &Some("Follow instructions".to_string()));
            }
            _ => panic!("Expected ShowAuth phase"),
        }
    }

    #[test]
    fn test_provider_login_dialog_manual_input() {
        let mut dialog = ProviderLoginDialog::new("anthropic", "Anthropic");
        dialog.show_manual_input("Paste code here");
        dialog.input_char('A');
        dialog.input_char('B');
        assert_eq!(dialog.input_value(), Some("AB"));
        dialog.backspace();
        assert_eq!(dialog.input_value(), Some("A"));
    }

    #[test]
    fn test_provider_login_dialog_prompt() {
        let mut dialog = ProviderLoginDialog::new("openai", "OpenAI");
        dialog.show_prompt("Enter API key", Some("sk-...".to_string()));
        dialog.input_char('x');
        assert_eq!(dialog.input_value(), Some("x"));
    }

    #[test]
    fn test_provider_login_dialog_waiting() {
        let mut dialog = ProviderLoginDialog::new("github", "GitHub");
        dialog.show_waiting("Polling for device code...");
        assert!(
            matches!(&dialog.phase, LoginDialogPhase::Waiting { message } if message == "Polling for device code...")
        );
    }

    #[test]
    fn test_provider_login_dialog_progress() {
        let mut dialog = ProviderLoginDialog::new("github", "GitHub");
        dialog.show_waiting("Waiting");
        dialog.show_progress("Still waiting");
        match &dialog.phase {
            LoginDialogPhase::Waiting { message } => {
                assert!(message.contains("Waiting"));
                assert!(message.contains("Still waiting"));
            }
            _ => panic!("Expected Waiting phase"),
        }
    }

    #[test]
    fn test_provider_login_dialog_complete() {
        let mut dialog = ProviderLoginDialog::new("anthropic", "Anthropic");
        dialog.complete(true, Some("Logged in".to_string()));
        match &dialog.phase {
            LoginDialogPhase::Completed { success, message } => {
                assert!(success);
                assert_eq!(message, &Some("Logged in".to_string()));
            }
            _ => panic!("Expected Completed phase"),
        }
    }

    #[test]
    fn test_provider_login_dialog_render() {
        let dialog =
            ProviderLoginDialog::new("anthropic", "Anthropic").with_title("Login to Anthropic");
        let lines = dialog.render();
        assert!(lines.iter().any(|l| l.contains("Login to Anthropic")));
    }

    #[test]
    fn test_oauth_selector_navigation() {
        let mut sel = OAuthSelector::new(
            OAuthSelectorMode::Login,
            vec![
                AuthProviderInfo {
                    id: "anthropic".into(),
                    name: "Anthropic".into(),
                    auth_type: AuthType::OAuth,
                },
                AuthProviderInfo {
                    id: "openai".into(),
                    name: "OpenAI".into(),
                    auth_type: AuthType::ApiKey,
                },
                AuthProviderInfo {
                    id: "github".into(),
                    name: "GitHub".into(),
                    auth_type: AuthType::OAuth,
                },
            ],
        );
        assert_eq!(sel.selected().unwrap().id, "anthropic");
        sel.move_down();
        assert_eq!(sel.selected().unwrap().id, "openai");
        sel.move_down();
        assert_eq!(sel.selected().unwrap().id, "github");
        sel.move_up();
        assert_eq!(sel.selected().unwrap().id, "openai");
    }

    #[test]
    fn test_oauth_selector_filter() {
        let mut sel = OAuthSelector::new(
            OAuthSelectorMode::Login,
            vec![
                AuthProviderInfo {
                    id: "anthropic".into(),
                    name: "Anthropic".into(),
                    auth_type: AuthType::OAuth,
                },
                AuthProviderInfo {
                    id: "openai".into(),
                    name: "OpenAI".into(),
                    auth_type: AuthType::ApiKey,
                },
            ],
        );
        sel.set_filter("open".to_string());
        assert_eq!(sel.filtered_indices.len(), 1);
        assert_eq!(sel.selected().unwrap().id, "openai");
    }

    #[test]
    fn test_oauth_selector_config_status() {
        let mut sel = OAuthSelector::new(
            OAuthSelectorMode::Login,
            vec![AuthProviderInfo {
                id: "anthropic".into(),
                name: "Anthropic".into(),
                auth_type: AuthType::OAuth,
            }],
        );
        sel.set_config_status(
            "anthropic",
            ProviderConfigStatus::Configured {
                label: "OAuth".to_string(),
            },
        );
        let status = sel.config_status.get("anthropic").unwrap();
        assert!(matches!(status, ProviderConfigStatus::Configured { label } if label == "OAuth"));
    }

    #[test]
    fn test_oauth_selector_render() {
        let sel = OAuthSelector::new(
            OAuthSelectorMode::Login,
            vec![AuthProviderInfo {
                id: "anthropic".into(),
                name: "Anthropic".into(),
                auth_type: AuthType::OAuth,
            }],
        );
        let lines = sel.render();
        assert!(lines.iter().any(|l| l.contains("Select provider")));
        assert!(lines.iter().any(|l| l.contains("Anthropic")));
    }

    #[test]
    fn test_oauth_selector_logout_mode() {
        let sel = OAuthSelector::new(OAuthSelectorMode::Logout, vec![]);
        let lines = sel.render();
        assert!(lines.iter().any(|l| l.contains("logout")));
    }

    #[test]
    fn test_bordered_loader() {
        let loader = BorderedLoader::new("Loading...", true);
        assert_eq!(loader.message, "Loading...");
        assert!(loader.cancellable);
        assert_eq!(loader.spinner_frame, 0);
    }

    #[test]
    fn test_bordered_loader_tick() {
        let mut loader = BorderedLoader::new("Working", false);
        assert_eq!(loader.spinner_char(), "⠋");
        loader.tick();
        assert_eq!(loader.spinner_frame, 1);
        assert_eq!(loader.spinner_char(), "⠙");
        loader.tick();
        assert_eq!(loader.spinner_char(), "⠹");
        loader.tick();
        assert_eq!(loader.spinner_char(), "⠸");
        loader.tick();
        assert_eq!(loader.spinner_frame, 0);
        assert_eq!(loader.spinner_char(), "⠋");
    }

    #[test]
    fn test_bordered_loader_render() {
        let loader = BorderedLoader::new("Fetching data", true);
        let lines = loader.render();
        assert!(lines.iter().any(|l| l.contains("Fetching data")));
        assert!(lines.iter().any(|l| l.contains("cancel")));
    }

    #[test]
    fn test_bordered_loader_render_non_cancellable() {
        let loader = BorderedLoader::new("Please wait", false);
        let lines = loader.render();
        assert!(lines.iter().any(|l| l.contains("Please wait")));
        assert!(!lines.iter().any(|l| l.contains("cancel")));
    }
}
