"""Gradio web interface for Chad."""

import os
import re
import gradio as gr
from pathlib import Path
import threading
import queue
from typing import Iterator

from .security import SecurityManager
from .session_manager import (
    SessionManager, get_coding_timeout, get_management_timeout,
    TaskPhase, INVESTIGATION_PROMPT, IMPLEMENTATION_PROMPT, VERIFICATION_PROMPT, SAFETY_CONSTRAINTS,
    CODING_INVESTIGATION_CONTEXT, CODING_IMPLEMENTATION_CONTEXT
)
from .providers import ModelConfig, parse_codex_output, extract_final_codex_response
from .model_catalog import ModelCatalog


# Custom styling for the provider management area to improve contrast between
# the summary header and each provider card.
PROVIDER_PANEL_CSS = """
:root {
  --task-btn-bg: #8fd3ff;
  --task-btn-border: #74c3f6;
  --task-btn-text: #0a2236;
  --task-btn-hover: #7bc9ff;
}

#start-task-btn,
#cancel-task-btn {
  background: var(--task-btn-bg) !important;
  border: 1px solid var(--task-btn-border) !important;
  color: var(--task-btn-text) !important;
  font-size: 0.85rem !important;
  min-height: 32px !important;
  padding: 6px 12px !important;
}

#start-task-btn:hover,
#cancel-task-btn:hover {
  background: var(--task-btn-hover) !important;
}

.provider-section-title {
  color: #e2e8f0;
  letter-spacing: 0.01em;
}

.provider-summary {
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 14px;
  padding: 12px 14px;
  box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
  color: #0f172a;
}

.provider-card {
  background: linear-gradient(135deg, #0c1424 0%, #0a1a32 100%);
  border: 1px solid #1f2b46;
  border-radius: 16px;
  padding: 14px 16px;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28);
  gap: 8px;
}

.provider-card:nth-of-type(even) {
  background: linear-gradient(135deg, #0b1b32 0%, #0c1324 100%);
  border-color: #243552;
}

.provider-card .provider-card__header-row,
.provider-card__header-row {
  display: flex;
  align-items: stretch;
  background: var(--task-btn-bg) !important;
  border: 1px solid var(--task-btn-border) !important;
  border-radius: 12px;
  padding: 8px 10px;
  gap: 8px;
}

.provider-card .provider-card__header-row .provider-card__header,
.provider-card .provider-card__header {
  background: var(--task-btn-bg) !important;
  color: var(--task-btn-text) !important;
  display: inline-flex;
  align-items: center;
  padding: 6px 10px;
  flex: 1;
  border-radius: 10px;
}

.provider-card .provider-card__header-row .provider-card__header-text,
.provider-card__header-row .provider-card__header-text {
  background: var(--task-btn-bg);
  color: var(--task-btn-text);
  padding: 6px 10px;
  border-radius: 10px;
  display: inline-flex;
  align-items: center;
  letter-spacing: 0.02em;
}

.provider-card .provider-card__header-row .provider-card__header .prose,
.provider-card .provider-card__header-row .provider-card__header .prose *,
.provider-card .provider-card__header .prose,
.provider-card .provider-card__header .prose * {
  color: var(--task-btn-text) !important;
  background: var(--task-btn-bg) !important;
  margin: 0;
  padding: 0;
}

.provider-card .provider-card__header-row .provider-card__header > *,
.provider-card .provider-card__header > * {
  background: var(--task-btn-bg) !important;
  color: var(--task-btn-text) !important;
}

.provider-card .provider-card__header-row .provider-card__header :is(h1, h2, h3, h4, h5, h6, p, span),
.provider-card .provider-card__header :is(h1, h2, h3, h4, h5, h6, p, span) {
  margin: 0;
  padding: 0;
  background: transparent !important;
  color: inherit !important;
}

.provider-card .provider-controls {
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid #243047;
  border-radius: 12px;
  padding: 10px 12px;
}

.provider-usage-title {
  margin-top: 10px !important;
  color: #475569;
  border-top: 1px solid #e2e8f0;
  padding-top: 8px;
  letter-spacing: 0.01em;
}

.provider-usage {
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  padding: 10px 12px;
  color: #0f172a;
  box-shadow: 0 4px 10px rgba(15, 23, 42, 0.06);
}

.provider-delete {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  min-width: 36px;
  max-width: 36px;
  flex-shrink: 0;
  padding: 4px;
  border-radius: 8px;
  background: var(--task-btn-bg) !important;
  border: 1px solid #f97373 !important;
  color: #000000 !important;
  font-size: 17px;
  line-height: 1;
  box-shadow: none;
}

#live-output-box {
  max-height: 220px;
  overflow-y: auto;
}

#live-stream-box {
  margin-top: 8px;
}

#live-stream-box .live-output-header {
  background: #2a2a3e;
  color: #a8d4ff;
  padding: 6px 12px;
  border-radius: 8px 8px 0 0;
  font-weight: 600;
  font-size: 12px;
  letter-spacing: 0.05em;
  margin: 0;
}

#live-stream-box .live-output-content {
  background: #1e1e2e !important;
  color: #e2e8f0 !important;
  border: 1px solid #555 !important;
  border-top: none !important;
  border-radius: 0 0 8px 8px !important;
  padding: 12px !important;
  margin: 0 !important;
  max-height: 400px;
  overflow-y: auto;
  overflow-anchor: none;
  white-space: pre-wrap;
  word-wrap: break-word;
  font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
  font-size: 13px;
  line-height: 1.5;
}

/* Syntax highlighting colors for live stream */
#live-stream-box .live-output-content .diff-add {
  color: #98c379 !important;
  background: rgba(152, 195, 121, 0.1) !important;
}
#live-stream-box .live-output-content .diff-remove {
  color: #e06c75 !important;
  background: rgba(224, 108, 117, 0.1) !important;
}
#live-stream-box .live-output-content .diff-header {
  color: #61afef !important;
  font-weight: bold;
}
#live-stream-box .live-output-content .tool-call {
  color: #c678dd !important;
  font-weight: bold;
}
#live-stream-box .live-output-content .file-path {
  color: #e5c07b !important;
}
#live-stream-box .live-output-content .code-block {
  background: rgba(0, 0, 0, 0.2) !important;
  padding: 2px 4px;
  border-radius: 3px;
}

#live-stream-box .live-output-content * {
  background: transparent !important;
}

/* Scroll position indicator */
#live-stream-box .scroll-indicator {
  position: absolute;
  bottom: 8px;
  right: 20px;
  background: rgba(97, 175, 239, 0.9);
  color: #1e1e2e;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 11px;
  cursor: pointer;
  z-index: 10;
  display: none;
}
#live-stream-box .scroll-indicator:hover {
  background: rgba(97, 175, 239, 1);
}
#live-stream-box {
  position: relative;
}

/* Agent communication chatbot - preserve scroll position */
.chatbot-container, [data-testid="chatbot"] {
  scroll-behavior: auto !important;
}

/* Agent communication chatbot - full-width speech bubbles */
#agent-chatbot .message-row,
#agent-chatbot .message {
  width: 100% !important;
  max-width: 100% !important;
  align-self: stretch !important;
}

#agent-chatbot .bubble-wrap,
#agent-chatbot .bubble,
#agent-chatbot .message-content,
#agent-chatbot .message .prose {
  width: 100% !important;
  max-width: 100% !important;
}
"""

# No JavaScript needed - using CSS for scroll behavior
LIVE_STREAM_JS = ""


def ansi_to_html(text: str) -> str:
    """Convert ANSI escape codes to HTML spans with colors.

    Preserves the terminal's native coloring instead of stripping it.
    """
    # ANSI color code to CSS color mapping
    colors = {
        '30': '#000000', '31': '#e06c75', '32': '#98c379', '33': '#e5c07b',
        '34': '#61afef', '35': '#c678dd', '36': '#56b6c2', '37': '#abb2bf',
        '90': '#5c6370', '91': '#e06c75', '92': '#98c379', '93': '#e5c07b',
        '94': '#61afef', '95': '#c678dd', '96': '#56b6c2', '97': '#ffffff',
    }
    bg_colors = {
        '40': '#000000', '41': '#e06c75', '42': '#98c379', '43': '#e5c07b',
        '44': '#61afef', '45': '#c678dd', '46': '#56b6c2', '47': '#abb2bf',
    }

    result = []
    i = 0
    current_styles = []

    while i < len(text):
        # Check for ANSI escape sequence
        if text[i] == '\x1b' and i + 1 < len(text) and text[i + 1] == '[':
            # Find the end of the escape sequence
            j = i + 2
            while j < len(text) and text[j] not in 'mHJK':
                j += 1
            if j < len(text) and text[j] == 'm':
                # Parse the codes
                codes = text[i + 2:j].split(';')
                for code in codes:
                    if code == '0' or code == '':
                        # Reset
                        if current_styles:
                            result.append('</span>' * len(current_styles))
                            current_styles = []
                    elif code == '1':
                        result.append('<span style="font-weight:bold">')
                        current_styles.append('bold')
                    elif code == '3':
                        result.append('<span style="font-style:italic">')
                        current_styles.append('italic')
                    elif code == '4':
                        result.append('<span style="text-decoration:underline">')
                        current_styles.append('underline')
                    elif code in colors:
                        result.append(f'<span style="color:{colors[code]}">')
                        current_styles.append('color')
                    elif code in bg_colors:
                        result.append(f'<span style="background-color:{bg_colors[code]}">')
                        current_styles.append('bg')
                i = j + 1
                continue
            elif j < len(text):
                # Other escape sequence (cursor movement, etc.) - skip it
                i = j + 1
                continue

        # Regular character - escape HTML entities
        char = text[i]
        if char == '<':
            result.append('&lt;')
        elif char == '>':
            result.append('&gt;')
        elif char == '&':
            result.append('&amp;')
        elif char == '\n':
            result.append('\n')
        else:
            result.append(char)
        i += 1

    # Close any remaining spans
    if current_styles:
        result.append('</span>' * len(current_styles))

    return ''.join(result)


def summarize_content(content: str, max_length: int = 200) -> str:
    """Create a meaningful summary of content for collapsed view.

    Tries to extract the most informative sentence describing what was done.
    """
    import re

    # Remove markdown formatting for cleaner summary
    clean = content.replace('**', '').replace('`', '').replace('# ', '')

    # Split into sentences
    sentences = re.split(r'(?<=[.!?])\s+', clean)

    # Action verbs that indicate a meaningful summary sentence
    action_patterns = [
        r"^I(?:'ve|'m| have| am| will| would|'ll)",
        r"^(?:Updated|Changed|Fixed|Added|Removed|Modified|Created|Implemented|Refactored)",
        r"^(?:The|This) (?:change|update|fix|modification)",
        r"^(?:Successfully|Done|Completed)",
    ]

    # Look for sentences with action verbs
    for sentence in sentences:
        sentence = sentence.strip()
        if not sentence or len(sentence) < 20:
            continue
        for pattern in action_patterns:
            if re.match(pattern, sentence, re.IGNORECASE):
                # Found a good summary sentence
                if len(sentence) <= max_length:
                    return sentence
                return sentence[:max_length].rsplit(' ', 1)[0] + '...'

    # Look for sentences mentioning file paths
    for sentence in sentences:
        sentence = sentence.strip()
        if re.search(r'[a-zA-Z_]+\.(py|js|ts|tsx|css|html|md|json|yaml|yml)', sentence):
            if len(sentence) <= max_length:
                return sentence
            return sentence[:max_length].rsplit(' ', 1)[0] + '...'

    # Fallback: get first meaningful paragraph
    first_para = clean.split('\n\n')[0].strip()
    # Skip if it's just a header or very short
    if len(first_para) < 20:
        for para in clean.split('\n\n')[1:]:
            if len(para.strip()) >= 20:
                first_para = para.strip()
                break

    if len(first_para) <= max_length:
        return first_para
    return first_para[:max_length].rsplit(' ', 1)[0] + '...'


def make_chat_message(speaker: str, content: str, collapsible: bool = True) -> dict:
    """Create a Gradio 6.x compatible chat message.

    Args:
        speaker: The speaker name (e.g., "MANAGEMENT AI", "CODING AI")
        content: The message content
        collapsible: Whether to make long messages collapsible with a summary
    """
    # Map speakers to roles
    # MANAGEMENT AI messages are 'user' (outgoing instructions)
    # CODING AI messages are 'assistant' (incoming responses)
    role = "user" if "MANAGEMENT" in speaker else "assistant"

    # For long content, make it collapsible with a summary
    if collapsible and len(content) > 300:
        summary = summarize_content(content)
        formatted = f"**{speaker}**\n\n{summary}\n\n<details><summary>Show full output</summary>\n\n{content}\n\n</details>"  # noqa: E501
    else:
        formatted = f"**{speaker}**\n\n{content}"

    return {"role": role, "content": formatted}


class ChadWebUI:
    """Web interface for Chad using Gradio."""

    def __init__(self, security_mgr: SecurityManager, main_password: str):
        self.security_mgr = security_mgr
        self.main_password = main_password
        self.session_manager = None
        self.active_sessions = {}
        self.cancel_requested = False
        self._active_coding_provider = None
        self._last_status_header = ""
        self._last_summary = ""
        self._last_all_messages: list[dict] = []
        # Number of provider cards to render; expanded during UI creation to allow new providers
        self.provider_card_count = 10
        self.model_catalog = ModelCatalog(security_mgr)

    SUPPORTED_PROVIDERS = {"anthropic", "openai", "gemini", "mistral"}
    OPENAI_REASONING_LEVELS = ["default", "low", "medium", "high", "xhigh"]

    def list_providers(self) -> str:
        """Summarize all configured providers with role and model."""
        accounts = self.security_mgr.list_accounts()
        role_assignments = self.security_mgr.list_role_assignments()

        if not accounts:
            return "No providers configured yet. Add a provider with the ➕ below."

        rows = []
        for account_name, provider in accounts.items():
            roles = [role for role, acct in role_assignments.items() if acct == account_name]
            role_str = f" — roles: {', '.join(roles)}" if roles else ""
            model = self.security_mgr.get_account_model(account_name)
            model_str = f" | preferred model: `{model}`" if model != 'default' else ""
            reasoning = self.security_mgr.get_account_reasoning(account_name)
            reasoning_str = f" | reasoning: `{reasoning}`" if reasoning != 'default' else ""
            rows.append(f"- **{account_name}** ({provider}){role_str}{model_str}{reasoning_str}")

        return "\n".join(rows)

    def _get_account_role(self, account_name: str) -> str | None:
        """Return the role assigned to the account, if any."""
        role_assignments = self.security_mgr.list_role_assignments()
        roles = [role for role, acct in role_assignments.items() if acct == account_name]
        if len(roles) == 0:
            return None
        if 'CODING' in roles and 'MANAGEMENT' in roles:
            return "BOTH"
        return roles[0]

    def get_provider_usage(self, account_name: str) -> str:
        """Get usage text for a single provider."""
        accounts = self.security_mgr.list_accounts()
        provider = accounts.get(account_name)

        if not provider:
            return "Select a provider to see usage details."

        if provider == 'openai':
            status_text = self._get_codex_usage(account_name)
        elif provider == 'anthropic':
            status_text = self._get_claude_usage()
        elif provider == 'gemini':
            status_text = self._get_gemini_usage()
        elif provider == 'mistral':
            status_text = self._get_mistral_usage()
        else:
            status_text = "⚠️ **Unknown provider**"

        return status_text

    def _progress_bar(self, utilization_pct: float, width: int = 20) -> str:
        """Create a text progress bar for usage displays."""
        filled = int(max(0.0, min(100.0, utilization_pct)) / (100 / width))
        return '█' * filled + '░' * (width - filled)

    def get_remaining_usage(self, account_name: str) -> float:
        """Get remaining usage as 0.0-1.0 (1.0 = full capacity remaining).

        Used to sort providers by availability - highest remaining usage first.
        """
        accounts = self.security_mgr.list_accounts()
        provider = accounts.get(account_name)

        if not provider:
            return 0.0

        if provider == 'anthropic':
            return self._get_claude_remaining_usage()
        elif provider == 'openai':
            return self._get_codex_remaining_usage(account_name)
        elif provider == 'gemini':
            return self._get_gemini_remaining_usage()
        elif provider == 'mistral':
            return self._get_mistral_remaining_usage()

        return 0.3  # Unknown provider, bias low

    def _get_claude_remaining_usage(self) -> float:
        """Get Claude remaining usage from API (0.0-1.0)."""
        import json
        import requests
        from pathlib import Path

        creds_file = Path.home() / ".claude" / ".credentials.json"
        if not creds_file.exists():
            return 0.0

        try:
            with open(creds_file) as f:
                creds = json.load(f)

            oauth_data = creds.get('claudeAiOauth', {})
            access_token = oauth_data.get('accessToken', '')
            if not access_token:
                return 0.0

            response = requests.get(
                'https://api.anthropic.com/api/oauth/usage',
                headers={
                    'Authorization': f'Bearer {access_token}',
                    'anthropic-beta': 'oauth-2025-04-20',
                    'User-Agent': 'claude-code/2.0.32',
                    'Content-Type': 'application/json',
                },
                timeout=10
            )

            if response.status_code != 200:
                return 0.3  # API error, bias low

            usage_data = response.json()
            five_hour = usage_data.get('five_hour', {})
            util = five_hour.get('utilization', 0)
            return max(0.0, min(1.0, 1.0 - (util / 100.0)))

        except Exception:
            return 0.3  # Error, bias low

    def _get_codex_remaining_usage(self, account_name: str) -> float:
        """Get Codex remaining usage from session files (0.0-1.0)."""
        import json
        import os
        from pathlib import Path

        codex_home = self._get_codex_home(account_name)
        auth_file = codex_home / ".codex" / "auth.json"
        if not auth_file.exists():
            return 0.0

        sessions_dir = codex_home / ".codex" / "sessions"
        if not sessions_dir.exists():
            return 0.8  # Logged in but no sessions, assume mostly available

        session_files = []
        for root, _, files in os.walk(sessions_dir):
            for f in files:
                if f.endswith('.jsonl'):
                    path = Path(root) / f
                    session_files.append((path.stat().st_mtime, path))

        if not session_files:
            return 0.8

        session_files.sort(reverse=True)
        latest_session = session_files[0][1]

        try:
            rate_limits = None
            with open(latest_session) as f:
                for line in f:
                    if 'rate_limits' in line:
                        data = json.loads(line.strip())
                        if data.get('type') == 'event_msg':
                            payload = data.get('payload', {})
                            if payload.get('type') == 'token_count':
                                rate_limits = payload.get('rate_limits')

            if rate_limits:
                primary = rate_limits.get('primary', {})
                if primary:
                    util = primary.get('used_percent', 0)
                    return max(0.0, min(1.0, 1.0 - (util / 100.0)))

        except Exception:
            pass

        return 0.3  # Error, bias low

    def _get_gemini_remaining_usage(self) -> float:
        """Estimate Gemini remaining usage (0.0-1.0).

        No programmatic API available for quota, so we estimate based on
        whether logged in. Biased low since we can't verify actual quota.
        """
        from pathlib import Path

        oauth_file = Path.home() / ".gemini" / "oauth_creds.json"
        if not oauth_file.exists():
            return 0.0

        return 0.3  # Logged in but no quota API, bias low

    def _get_mistral_remaining_usage(self) -> float:
        """Estimate Mistral remaining usage (0.0-1.0).

        No programmatic API available for quota, so we estimate based on
        whether logged in. Biased low since we can't verify actual quota.
        """
        from pathlib import Path

        vibe_config = Path.home() / ".vibe" / "config.toml"
        if not vibe_config.exists():
            return 0.0

        return 0.3  # Logged in but no quota API, bias low

    def _provider_state(self) -> tuple:
        """Build UI state for provider cards (summary + per-account controls)."""
        accounts = self.security_mgr.list_accounts()
        # Sort accounts by remaining_usage (highest first)
        account_items = sorted(
            accounts.items(),
            key=lambda x: self.get_remaining_usage(x[0]),
            reverse=True
        )
        list_md = self.list_providers()

        outputs: list = [list_md]
        card_slots = self.provider_card_count

        for idx in range(card_slots):
            if idx < len(account_items):
                account_name, provider = account_items[idx]
                header = f'<span class="provider-card__header-text">{account_name} ({provider})</span>'
                current_role = self._get_account_role(account_name)
                role_value = current_role if current_role else "(none)"
                model_choices = self.get_models_for_account(account_name)
                stored_model = self.security_mgr.get_account_model(account_name)
                model_value = stored_model if stored_model in model_choices else model_choices[0]
                reasoning_choices = self.get_reasoning_choices(provider, account_name)
                stored_reasoning = self.security_mgr.get_account_reasoning(account_name)
                reasoning_value = stored_reasoning if stored_reasoning in reasoning_choices else reasoning_choices[0]
                usage = self.get_provider_usage(account_name)

                outputs.extend([
                    gr.update(visible=True),
                    header,
                    account_name,
                    gr.update(value=role_value),
                    gr.update(choices=model_choices, value=model_value),
                    gr.update(choices=reasoning_choices, value=reasoning_value),
                    usage
                ])
            else:
                outputs.extend([
                    gr.update(visible=False),
                    "",
                    "",
                    gr.update(value="(none)"),
                    gr.update(choices=['default'], value='default'),
                    gr.update(choices=['default'], value='default'),
                    ""
                ])

        return tuple(outputs)

    def _provider_action_response(self, feedback: str):
        """Return standard provider panel updates with feedback text."""
        return (feedback, *self._provider_state())

    def _get_codex_home(self, account_name: str) -> Path:
        """Get the isolated HOME directory for a Codex account."""
        from pathlib import Path
        return Path.home() / ".chad" / "codex-homes" / account_name

    def _get_codex_usage(self, account_name: str) -> str:
        """Get usage info from Codex by parsing JWT token and session files."""
        import json
        import base64
        from datetime import datetime

        codex_home = self._get_codex_home(account_name)
        auth_file = codex_home / ".codex" / "auth.json"
        if not auth_file.exists():
            return "❌ **Not logged in**\n\nClick 'Login' to authenticate this account."

        try:
            with open(auth_file) as f:
                auth_data = json.load(f)

            tokens = auth_data.get('tokens', {})
            access_token = tokens.get('access_token', '')

            if not access_token:
                return "❌ **Not logged in**\n\nClick 'Login' to authenticate this account."

            # Decode JWT payload (middle part)
            parts = access_token.split('.')
            if len(parts) != 3:
                return "⚠️ **Invalid token format**"

            # Add padding for base64 decode
            payload = parts[1]
            padding = 4 - len(payload) % 4
            if padding != 4:
                payload += '=' * padding

            decoded = base64.urlsafe_b64decode(payload)
            jwt_data = json.loads(decoded)

            # Extract info
            auth_info = jwt_data.get('https://api.openai.com/auth', {})
            profile = jwt_data.get('https://api.openai.com/profile', {})

            plan_type = auth_info.get('chatgpt_plan_type', 'unknown').upper()
            email = profile.get('email', 'Unknown')
            exp_timestamp = jwt_data.get('exp', 0)

            # Format expiration
            exp_date = datetime.fromtimestamp(exp_timestamp).strftime('%Y-%m-%d %H:%M') if exp_timestamp else 'Unknown'

            result = f"✅ **Logged in** ({plan_type} plan)\n\n"
            result += f"**Account:** {email}\n"
            result += f"**Token expires:** {exp_date}\n\n"

            # Try to get usage data from Codex session files
            usage_data = self._get_codex_session_usage(account_name)
            if usage_data:
                result += "**Current Usage**\n\n"
                result += usage_data
            else:
                result += "⚠️ **Usage data unavailable**\n\n"
                result += "OpenAI/Codex only provides usage information after the first model interaction. "
                result += "Start a coding session to see rate limit details.\n\n"
                result += "*Press refresh after using this provider to see current data*"

            return result

        except Exception as e:
            return f"⚠️ **Error reading auth data:** {str(e)}"

    def _get_codex_session_usage(self, account_name: str) -> str | None:  # noqa: C901
        """Extract usage data from the most recent Codex session file."""
        import json
        import os
        from pathlib import Path
        from datetime import datetime

        codex_home = self._get_codex_home(account_name)
        sessions_dir = codex_home / ".codex" / "sessions"
        if not sessions_dir.exists():
            return None

        # Find the most recent session file
        session_files = []
        for root, _, files in os.walk(sessions_dir):
            for f in files:
                if f.endswith('.jsonl'):
                    path = Path(root) / f
                    session_files.append((path.stat().st_mtime, path))

        if not session_files:
            return None

        # Sort by modification time, most recent first
        session_files.sort(reverse=True)
        latest_session = session_files[0][1]

        # Read the file and find the last rate_limits entry
        rate_limits = None
        timestamp = None
        try:
            with open(latest_session) as f:
                for line in f:
                    if 'rate_limits' in line:
                        data = json.loads(line.strip())
                        if data.get('type') == 'event_msg':
                            payload = data.get('payload', {})
                            if payload.get('type') == 'token_count':
                                rate_limits = payload.get('rate_limits')
                                timestamp = data.get('timestamp')
        except (json.JSONDecodeError, OSError):
            return None

        if not rate_limits:
            return None

        result = ""

        # 5-hour limit (primary)
        primary = rate_limits.get('primary', {})
        if primary:
            util = primary.get('used_percent', 0)
            reset_at = primary.get('resets_at', 0)

            bar = self._progress_bar(util)

            # Format reset time
            if reset_at:
                reset_dt = datetime.fromtimestamp(reset_at)
                reset_str = reset_dt.strftime('%I:%M%p')
            else:
                reset_str = 'N/A'

            result += "**5-hour session**\n"
            result += f"[{bar}] {util:.0f}% used\n"
            result += f"Resets at {reset_str}\n\n"

        # Weekly limit (secondary)
        secondary = rate_limits.get('secondary', {})
        if secondary:
            util = secondary.get('used_percent', 0)
            reset_at = secondary.get('resets_at', 0)

            bar = self._progress_bar(util)

            if reset_at:
                reset_dt = datetime.fromtimestamp(reset_at)
                reset_str = reset_dt.strftime('%b %d')
            else:
                reset_str = 'N/A'

            result += "**Weekly limit**\n"
            result += f"[{bar}] {util:.0f}% used\n"
            result += f"Resets {reset_str}\n\n"

        # Credits (if available)
        credits = rate_limits.get('credits', {})
        if credits:
            has_credits = credits.get('has_credits', False)
            unlimited = credits.get('unlimited', False)
            balance = credits.get('balance')

            if unlimited:
                result += "**Credits:** Unlimited\n\n"
            elif has_credits and balance is not None:
                result += f"**Credits balance:** ${balance}\n\n"

        # Show when data was last updated
        if timestamp:
            try:
                update_dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
                result += f"*Last updated: {update_dt.strftime('%Y-%m-%d %H:%M UTC')}*\n"
            except ValueError:
                pass

        return result if result else None

    def _get_claude_usage(self) -> str:  # noqa: C901
        """Get usage info from Claude via API."""
        import json
        import requests
        from pathlib import Path
        from datetime import datetime

        creds_file = Path.home() / ".claude" / ".credentials.json"
        if not creds_file.exists():
            return "❌ **Not logged in**\n\nRun `claude` in terminal to authenticate."

        try:
            with open(creds_file) as f:
                creds = json.load(f)

            oauth_data = creds.get('claudeAiOauth', {})
            access_token = oauth_data.get('accessToken', '')
            subscription_type = oauth_data.get('subscriptionType', 'unknown').upper()

            if not access_token:
                return "❌ **Not logged in**\n\nRun `claude` in terminal to authenticate."

            # Call the usage API
            response = requests.get(
                'https://api.anthropic.com/api/oauth/usage',
                headers={
                    'Authorization': f'Bearer {access_token}',
                    'anthropic-beta': 'oauth-2025-04-20',
                    'User-Agent': 'claude-code/2.0.32',
                    'Content-Type': 'application/json',
                },
                timeout=10
            )

            if response.status_code != 200:
                return f"⚠️ **Error fetching usage:** HTTP {response.status_code}"

            usage_data = response.json()

            # Build the display
            result = f"✅ **Logged in** ({subscription_type} plan)\n\n"
            result += "**Current Usage**\n\n"

            # 5-hour limit
            five_hour = usage_data.get('five_hour', {})
            if five_hour:
                util = five_hour.get('utilization', 0)
                reset_at = five_hour.get('resets_at', '')

                bar = self._progress_bar(util)

                # Format reset time
                if reset_at:
                    try:
                        reset_dt = datetime.fromisoformat(reset_at.replace('Z', '+00:00'))
                        reset_str = reset_dt.strftime('%I:%M%p')
                    except ValueError:
                        reset_str = reset_at
                else:
                    reset_str = 'N/A'

                result += "**5-hour session**\n"
                result += f"[{bar}] {util:.0f}% used\n"
                result += f"Resets at {reset_str}\n\n"

            # 7-day limit (if present)
            seven_day = usage_data.get('seven_day')
            if seven_day:
                util = seven_day.get('utilization', 0)
                reset_at = seven_day.get('resets_at', '')

                bar = self._progress_bar(util)

                if reset_at:
                    try:
                        reset_dt = datetime.fromisoformat(reset_at.replace('Z', '+00:00'))
                        reset_str = reset_dt.strftime('%b %d')
                    except ValueError:
                        reset_str = reset_at
                else:
                    reset_str = 'N/A'

                result += "**Weekly limit**\n"
                result += f"[{bar}] {util:.0f}% used\n"
                result += f"Resets {reset_str}\n\n"

            # Extra usage (if enabled)
            extra = usage_data.get('extra_usage', {})
            if extra and extra.get('is_enabled'):
                used = extra.get('used_credits', 0)
                limit = extra.get('monthly_limit', 0)
                util = extra.get('utilization', 0)

                bar = self._progress_bar(util)

                result += "**Extra credits**\n"
                result += f"[{bar}] ${used:.0f} / ${limit:.0f} ({util:.1f}%)\n\n"

            return result

        except requests.exceptions.RequestException as e:
            return f"⚠️ **Network error:** {str(e)}"
        except Exception as e:
            return f"⚠️ **Error:** {str(e)}"

    def _get_gemini_usage(self) -> str:  # noqa: C901
        """Get usage info from Gemini by parsing session files."""
        import json
        from pathlib import Path
        from collections import defaultdict

        gemini_dir = Path.home() / ".gemini"
        oauth_file = gemini_dir / "oauth_creds.json"

        if not oauth_file.exists():
            return "❌ **Not logged in**\n\nRun `gemini` in terminal to authenticate."

        # Find all session files
        tmp_dir = gemini_dir / "tmp"
        if not tmp_dir.exists():
            return ("✅ **Logged in**\n\n"
                    "⚠️ **Usage data unavailable**\n\n"
                    "Google Gemini only provides usage information after the first model interaction. "
                    "Start a coding session to see token usage details.\n\n"
                    "*Press refresh after using this provider to see current data*")

        session_files = list(tmp_dir.glob("*/chats/session-*.json"))
        if not session_files:
            return ("✅ **Logged in**\n\n"
                    "⚠️ **Usage data unavailable**\n\n"
                    "Google Gemini only provides usage information after the first model interaction. "
                    "Start a coding session to see token usage details.\n\n"
                    "*Press refresh after using this provider to see current data*")

        # Aggregate token usage by model
        model_usage = defaultdict(lambda: {"requests": 0, "input_tokens": 0, "output_tokens": 0, "cached_tokens": 0})

        for session_file in session_files:
            try:
                with open(session_file) as f:
                    session_data = json.load(f)

                messages = session_data.get("messages", [])
                for msg in messages:
                    if msg.get("type") == "gemini":
                        tokens = msg.get("tokens", {})
                        model = msg.get("model", "unknown")

                        model_usage[model]["requests"] += 1
                        model_usage[model]["input_tokens"] += tokens.get("input", 0)
                        model_usage[model]["output_tokens"] += tokens.get("output", 0)
                        model_usage[model]["cached_tokens"] += tokens.get("cached", 0)
            except (json.JSONDecodeError, OSError, KeyError):
                continue

        if not model_usage:
            return "✅ **Logged in**\n\n*No usage data yet*"

        # Build display
        result = "✅ **Logged in**\n\n"
        result += "**Model Usage**\n\n"
        result += "| Model | Reqs | Input | Output |\n"
        result += "|-------|------|-------|--------|\n"

        total_input = 0
        total_output = 0
        total_cached = 0
        total_requests = 0

        for model, usage in sorted(model_usage.items()):
            reqs = usage["requests"]
            input_tok = usage["input_tokens"]
            output_tok = usage["output_tokens"]
            cached_tok = usage["cached_tokens"]

            total_requests += reqs
            total_input += input_tok
            total_output += output_tok
            total_cached += cached_tok

            result += f"| {model} | {reqs:,} | {input_tok:,} | {output_tok:,} |\n"

        # Summary with cache savings
        if total_cached > 0 and total_input > 0:
            cache_pct = (total_cached / total_input) * 100
            result += f"\n**Cache savings:** {total_cached:,} ({cache_pct:.1f}%) tokens served from cache\n"

        return result

    def _get_mistral_usage(self) -> str:
        """Get usage info from Mistral Vibe by parsing session files."""
        import json
        from pathlib import Path

        vibe_config = Path.home() / ".vibe" / "config.toml"
        if not vibe_config.exists():
            return "❌ **Not logged in**\n\nRun `vibe --setup` in terminal to authenticate."

        # Find session files in default location
        sessions_dir = Path.home() / ".vibe" / "logs" / "session"
        if not sessions_dir.exists():
            return "✅ **Logged in**\n\n*No session data yet*"

        session_files = list(sessions_dir.glob("session_*.json"))
        if not session_files:
            return "✅ **Logged in**\n\n*No session data yet*"

        # Aggregate stats from all session files
        total_prompt_tokens = 0
        total_completion_tokens = 0
        total_cost = 0.0
        session_count = 0

        for session_file in session_files:
            try:
                with open(session_file) as f:
                    data = json.load(f)

                metadata = data.get("metadata", {})
                stats = metadata.get("stats", {})

                total_prompt_tokens += stats.get("session_prompt_tokens", 0)
                total_completion_tokens += stats.get("session_completion_tokens", 0)
                total_cost += stats.get("session_cost", 0.0)
                session_count += 1
            except (json.JSONDecodeError, OSError, KeyError):
                continue

        if session_count == 0:
            return "✅ **Logged in**\n\n*No valid session data found*"

        total_tokens = total_prompt_tokens + total_completion_tokens

        result = "✅ **Logged in**\n\n"
        result += "**Cumulative Usage**\n\n"
        result += f"**Sessions:** {session_count:,}\n"
        result += f"**Input tokens:** {total_prompt_tokens:,}\n"
        result += f"**Output tokens:** {total_completion_tokens:,}\n"
        result += f"**Total tokens:** {total_tokens:,}\n"
        result += f"**Estimated cost:** ${total_cost:.4f}\n"

        return result

    def get_account_choices(self) -> list[str]:
        """Get list of account names for dropdowns."""
        return list(self.security_mgr.list_accounts().keys())

    def _check_provider_login(self, provider_type: str, account_name: str) -> tuple[bool, str]:  # noqa: C901
        """Check if a provider is logged in."""
        from pathlib import Path

        try:
            if provider_type == 'openai':
                codex_home = self._get_codex_home(account_name)
                auth_file = codex_home / ".codex" / "auth.json"
                if auth_file.exists():
                    return True, "Logged in"
                return False, "Not logged in"

            elif provider_type == 'anthropic':
                creds_file = Path.home() / ".claude" / ".credentials.json"
                if creds_file.exists():
                    return True, "Logged in"
                return False, "Not logged in"

            elif provider_type == 'gemini':
                # Check for Gemini CLI's own oauth credentials, not gcloud
                gemini_oauth = Path.home() / ".gemini" / "oauth_creds.json"
                if gemini_oauth.exists():
                    return True, "Logged in"
                return False, "Not logged in"

            elif provider_type == 'mistral':
                # Check for Vibe config file
                vibe_config = Path.home() / ".vibe" / "config.toml"
                if vibe_config.exists():
                    return True, "Logged in"
                return False, "Not logged in"

            return False, "Unknown provider type"

        except Exception as e:
            return False, f"Error: {str(e)}"

    def _setup_codex_account(self, account_name: str) -> str:
        """Setup isolated home directory for a Codex account."""
        codex_home = self._get_codex_home(account_name)
        codex_dir = codex_home / ".codex"
        codex_dir.mkdir(parents=True, exist_ok=True)
        return str(codex_home)

    def login_codex_account(self, account_name: str) -> str:
        """Initiate login for a Codex account. Returns instructions for the user."""
        import subprocess
        import os

        if not account_name:
            return "❌ Please select an account to login"

        accounts = self.security_mgr.list_accounts()
        if account_name not in accounts:
            return f"❌ Account '{account_name}' not found"

        if accounts[account_name] != 'openai':
            return f"❌ Account '{account_name}' is not an OpenAI account"

        # Setup isolated home
        codex_home = self._setup_codex_account(account_name)

        # Create environment with isolated HOME
        env = os.environ.copy()
        env['HOME'] = codex_home

        # First logout any existing session
        subprocess.run(['codex', 'logout'], env=env, capture_output=True, timeout=10)

        # Now run login - this will open a browser
        result = subprocess.run(
            ['codex', 'login'],
            env=env,
            capture_output=True,
            text=True,
            timeout=120
        )

        if result.returncode == 0:
            return f"✅ **Login successful for '{account_name}'!**\n\nRefresh the Usage Statistics to see account details."  # noqa: E501
        else:
            error = result.stderr.strip() if result.stderr else "Unknown error"
            return f"⚠️ **Login may have failed**\n\n{error}\n\nTry refreshing Usage Statistics to check status."

    def add_provider(self, provider_name: str, provider_type: str):  # noqa: C901
        """Add a new provider and return refreshed provider panel state."""
        import subprocess
        import os

        # Default form updates: keep current entry and leave accordion open
        name_field_value = provider_name
        add_btn_state = gr.update(interactive=bool(provider_name.strip()))
        accordion_state = gr.update(open=True)

        try:
            if provider_type not in self.SUPPORTED_PROVIDERS:
                base_response = self._provider_action_response(f"❌ Unsupported provider '{provider_type}'")
                return (*base_response, name_field_value, add_btn_state, accordion_state)

            existing_accounts = self.security_mgr.list_accounts()
            base_name = provider_type
            counter = 1
            account_name = provider_name if provider_name else base_name

            while account_name in existing_accounts:
                account_name = f"{base_name}-{counter}"
                counter += 1

            if provider_type == 'openai':
                # For OpenAI, setup isolated home and run login immediately
                codex_home = self._setup_codex_account(account_name)

                # Create environment with isolated HOME
                env = os.environ.copy()
                env['HOME'] = codex_home

                # Run login - this will open a browser
                login_result = subprocess.run(
                    ['codex', 'login'],
                    env=env,
                    capture_output=True,
                    text=True,
                    timeout=120
                )

                if login_result.returncode == 0:
                    # Login succeeded, now save the account
                    self.security_mgr.store_account(account_name, provider_type, "", self.main_password)
                    result = f"✅ Provider '{account_name}' added and logged in!"
                    name_field_value = ""
                    add_btn_state = gr.update(interactive=False)
                    accordion_state = gr.update(open=False)
                else:
                    # Login failed, clean up
                    import shutil
                    codex_home_path = self._get_codex_home(account_name)
                    if codex_home_path.exists():
                        shutil.rmtree(codex_home_path, ignore_errors=True)

                    error = login_result.stderr.strip() if login_result.stderr else "Login was cancelled or failed"
                    result = f"❌ Login failed for '{account_name}': {error}"
                    base_response = self._provider_action_response(result)
                    return (*base_response, name_field_value, add_btn_state, accordion_state)

            else:
                # For other providers, store account and check login status
                self.security_mgr.store_account(account_name, provider_type, "", self.main_password)
                result = f"✓ Provider '{account_name}' ({provider_type}) added."

                login_success, login_msg = self._check_provider_login(provider_type, account_name)

                if login_success:
                    result += f" ✅ {login_msg}"
                else:
                    result += f" ⚠️ {login_msg}"

                    # Provide manual login instructions
                    auth_info = {
                        'anthropic': ('claude', 'Opens browser to authenticate with your Anthropic account'),
                        'gemini': ('gemini', 'Opens browser to authenticate with your Google account'),
                        'mistral': ('vibe --setup', 'Set up your Mistral API key')
                    }
                    auth_cmd, auth_desc = auth_info.get(provider_type, ('unknown', ''))
                    result += f" — manual login: run `{auth_cmd}` ({auth_desc})"

                # Only clear the form on successful add of non-OpenAI accounts
                name_field_value = ""
                add_btn_state = gr.update(interactive=False)
                accordion_state = gr.update(open=False)

            base_response = self._provider_action_response(result)
            return (*base_response, name_field_value, add_btn_state, accordion_state)
        except subprocess.TimeoutExpired:
            # Clean up on timeout
            import shutil
            codex_home_path = self._get_codex_home(account_name)
            if codex_home_path.exists():
                shutil.rmtree(codex_home_path, ignore_errors=True)
            base_response = self._provider_action_response(f"❌ Login timed out for '{account_name}'. Please try again.")
            return (*base_response, name_field_value, add_btn_state, accordion_state)
        except Exception as e:
            base_response = self._provider_action_response(f"❌ Error adding provider: {str(e)}")
            return (*base_response, name_field_value, add_btn_state, accordion_state)

    def _unassign_account_roles(self, account_name: str) -> None:
        """Remove all role assignments for an account."""
        role_assignments = self.security_mgr.list_role_assignments()
        for role, acct in list(role_assignments.items()):
            if acct == account_name:
                self.security_mgr.clear_role(role)

    def get_role_config_status(self) -> tuple[bool, str]:
        """Check if roles are properly configured for running tasks.

        Returns:
            Tuple of (is_ready, status_message) where status includes model assignments when ready
        """
        role_assignments = self.security_mgr.list_role_assignments()
        coding_account = role_assignments.get('CODING')
        management_account = role_assignments.get('MANAGEMENT')

        missing = []
        if not coding_account:
            missing.append("CODING")
        if not management_account:
            missing.append("MANAGEMENT")

        if missing:
            return False, f"⚠️ Missing role assignments: {', '.join(missing)}. Configure in Providers tab."

        # Build model assignment info
        accounts = self.security_mgr.list_accounts()
        coding_provider = accounts.get(coding_account, 'unknown')
        coding_model = self.security_mgr.get_account_model(coding_account)
        coding_model_str = coding_model if coding_model != 'default' else ''

        management_provider = accounts.get(management_account, 'unknown')
        management_model = self.security_mgr.get_account_model(management_account)
        management_model_str = management_model if management_model != 'default' else ''

        # Format: ✓ Ready — Coding: account (provider, model) | Management: account (provider, model)
        coding_info = f"{coding_account} ({coding_provider}"
        if coding_model_str:
            coding_info += f", {coding_model_str}"
        coding_info += ")"

        mgmt_info = f"{management_account} ({management_provider}"
        if management_model_str:
            mgmt_info += f", {management_model_str}"
        mgmt_info += ")"

        return True, f"✓ Ready — **Coding:** {coding_info} | **Management:** {mgmt_info}"

    def assign_role(self, account_name: str, role: str):
        """Assign a role to a provider and refresh the provider panel."""
        try:
            if not account_name:
                return self._provider_action_response("❌ Please select an account to assign a role")
            if not role or not str(role).strip():
                return self._provider_action_response("❌ Please select a role")

            accounts = self.security_mgr.list_accounts()
            if account_name not in accounts:
                return self._provider_action_response(f"❌ Provider '{account_name}' not found")

            if role == '(none)':
                # Clear all roles for this account
                self._unassign_account_roles(account_name)
                return self._provider_action_response(f"✓ Removed role assignments from {account_name}")

            # First, remove any existing roles for this account
            self._unassign_account_roles(account_name)

            if role.upper() == "BOTH":
                self.security_mgr.assign_role(account_name, "CODING")
                self.security_mgr.assign_role(account_name, "MANAGEMENT")
                return self._provider_action_response(f"✓ Assigned CODING and MANAGEMENT roles to {account_name}")
            else:
                self.security_mgr.assign_role(account_name, role.upper())
                return self._provider_action_response(f"✓ Assigned {role.upper()} role to {account_name}")
        except Exception as e:
            return self._provider_action_response(f"❌ Error assigning role: {str(e)}")

    def set_model(self, account_name: str, model: str):
        """Set the model for a provider account and refresh the provider panel."""
        try:
            if not account_name:
                return self._provider_action_response("❌ Please select an account")

            if not model:
                return self._provider_action_response("❌ Please select a model")

            accounts = self.security_mgr.list_accounts()
            if account_name not in accounts:
                return self._provider_action_response(f"❌ Provider '{account_name}' not found")

            self.security_mgr.set_account_model(account_name, model)
            return self._provider_action_response(f"✓ Set model to `{model}` for {account_name}")
        except Exception as e:
            return self._provider_action_response(f"❌ Error setting model: {str(e)}")

    def set_reasoning(self, account_name: str, reasoning: str):
        """Set reasoning effort for a provider account and refresh the provider panel."""
        try:
            if not account_name:
                return self._provider_action_response("❌ Please select an account")

            if not reasoning:
                return self._provider_action_response("❌ Please select a reasoning level")

            accounts = self.security_mgr.list_accounts()
            if account_name not in accounts:
                return self._provider_action_response(f"❌ Provider '{account_name}' not found")

            self.security_mgr.set_account_reasoning(account_name, reasoning)
            return self._provider_action_response(f"✓ Set reasoning to `{reasoning}` for {account_name}")
        except Exception as e:
            return self._provider_action_response(f"❌ Error setting reasoning: {str(e)}")

    def get_models_for_account(self, account_name: str) -> list[str]:
        """Get available models for an account based on its provider."""
        if not account_name:
            return ['default']

        accounts = self.security_mgr.list_accounts()
        provider = accounts.get(account_name, '')
        return self.model_catalog.get_models(provider, account_name)

    def get_reasoning_choices(self, provider: str, account_name: str | None = None) -> list[str]:
        """Return reasoning dropdown options for the provider."""
        if provider == 'openai':
            stored = 'default'
            if account_name:
                getter = getattr(self.security_mgr, "get_account_reasoning", None)
                if getter:
                    try:
                        stored = getter(account_name) or 'default'
                    except Exception:
                        stored = 'default'
            stored = stored if isinstance(stored, str) else 'default'
            choices = set(self.OPENAI_REASONING_LEVELS)
            if stored:
                choices.add(stored)
            ordered = [level for level in self.OPENAI_REASONING_LEVELS if level in choices]
            for choice in sorted(choices):
                if choice not in ordered:
                    ordered.append(choice)
            return ordered
        return ['default']

    def delete_provider(self, account_name: str, confirmed: bool = False):
        """Delete a provider after confirmation and refresh the provider panel."""
        import shutil

        try:
            if not account_name:
                return self._provider_action_response("❌ No provider selected")

            if not confirmed:
                return self._provider_action_response("Deletion cancelled.")

            # Check if it's an OpenAI account and clean up isolated home
            accounts = self.security_mgr.list_accounts()
            if accounts.get(account_name) == 'openai':
                codex_home = self._get_codex_home(account_name)
                if codex_home.exists():
                    shutil.rmtree(codex_home, ignore_errors=True)

            self.security_mgr.delete_account(account_name)
            return self._provider_action_response(f"✓ Provider '{account_name}' deleted")
        except Exception as e:
            return self._provider_action_response(f"❌ Error deleting provider: {str(e)}")

    def cancel_task(self) -> str:
        """Cancel the running task."""
        self.cancel_requested = True
        if self.session_manager:
            self.session_manager.stop_all()
            self.session_manager = None
        if self._active_coding_provider:
            self._active_coding_provider.stop_session()
            self._active_coding_provider = None
        return "🛑 Task cancelled"

    def _get_session_log_dir(self) -> Path:
        """Get the Chad session log directory, creating it if needed."""
        import tempfile
        log_dir = Path(tempfile.gettempdir()) / "chad"
        log_dir.mkdir(exist_ok=True)
        return log_dir

    def _create_session_log(
        self,
        task_description: str,
        project_path: str,
        coding_account: str,
        coding_provider: str,
        management_account: str,
        management_provider: str,
        managed_mode: bool = False
    ) -> Path:
        """Create a new session log file and return its path.

        The log is created at session start and updated throughout.
        """
        import json
        from datetime import datetime

        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filename = f"chad_session_{timestamp}.json"
        filepath = self._get_session_log_dir() / filename

        session_data = {
            "timestamp": datetime.now().isoformat(),
            "task_description": task_description,
            "project_path": project_path,
            "managed_mode": managed_mode,
            "coding": {
                "account": coding_account,
                "provider": coding_provider
            },
            "management": {
                "account": management_account,
                "provider": management_provider
            },
            "status": "running",
            "success": None,
            "completion_reason": None,
            "conversation": []
        }

        with open(filepath, 'w') as f:
            json.dump(session_data, f, indent=2)

        return filepath

    def _update_session_log(
        self,
        filepath: Path,
        chat_history: list,
        success: bool | None = None,
        completion_reason: str | None = None,
        status: str = "running"
    ) -> None:
        """Update an existing session log with new conversation data."""
        import json

        try:
            with open(filepath, 'r') as f:
                session_data = json.load(f)

            session_data["conversation"] = chat_history
            session_data["status"] = status
            if success is not None:
                session_data["success"] = success
            if completion_reason is not None:
                session_data["completion_reason"] = completion_reason

            with open(filepath, 'w') as f:
                json.dump(session_data, f, indent=2)
        except Exception:
            pass  # Don't fail the task if logging fails

    def start_chad_task(  # noqa: C901
        self,
        project_path: str,
        task_description: str,
        managed_mode: bool = False
    ) -> Iterator[tuple[list, str, str, gr.Radio, gr.Markdown, list, gr.Textbox, gr.TextArea, gr.Button, gr.Button]]:
        """Start Chad task and stream updates.

        Flow: Management AI plans first, then coding AI executes.
        """
        chat_history = []
        message_queue = queue.Queue()
        self.cancel_requested = False
        self._last_status_header = ""
        self._last_summary = ""
        self._last_all_messages = []
        current_summary = ""

        def make_yield(
            history,
            status: str,
            live_stream: str = "",
            summary: str | None = None,
            tabs_visible: bool = False,
            interactive: bool = False
        ):
            """Format output tuple for Gradio with current UI state."""
            nonlocal current_summary
            if summary is not None:
                current_summary = summary
            display_stream = live_stream
            tabs_update = gr.update(choices=["All"], value="All", visible=tabs_visible)
            all_messages_state = list(history)
            self._last_status_header = status
            self._last_summary = current_summary
            self._last_all_messages = all_messages_state
            # Only show status header for errors (indicated by ❌)
            is_error = '❌' in status
            return (
                history,
                display_stream,
                gr.update(value="", visible=False),  # subtask_list - always hidden
                tabs_update,
                gr.update(value=status if is_error else "", visible=is_error),  # task_status_header - errors only
                all_messages_state,
                gr.update(value=project_path, interactive=interactive),
                gr.update(value=task_description, interactive=interactive),
                gr.update(interactive=interactive),
                gr.update(interactive=not interactive)
            )

        try:
            # Validate inputs
            if not project_path or not task_description:
                error_msg = "❌ Please provide both project path and task description"
                yield make_yield([], error_msg, summary=error_msg, interactive=True)
                return

            path = Path(project_path).expanduser().resolve()
            if not path.exists() or not path.is_dir():
                error_msg = f"❌ Invalid project path: {project_path}"
                yield make_yield([], error_msg, summary=error_msg, interactive=True)
                return

            # Get role assignments
            role_assignments = self.security_mgr.list_role_assignments()
            coding_account = role_assignments.get('CODING')
            management_account = role_assignments.get('MANAGEMENT')

            if not coding_account or not management_account:
                msg = "❌ Please assign CODING and MANAGEMENT roles in the Provider Management tab first"
                yield make_yield([], msg, summary=msg, interactive=True)
                return

            # Get provider info
            accounts = self.security_mgr.list_accounts()
            coding_provider = accounts[coding_account]
            management_provider = accounts[management_account]

            # Create configs with stored models
            coding_model = self.security_mgr.get_account_model(coding_account)
            management_model = self.security_mgr.get_account_model(management_account)
            coding_reasoning = self.security_mgr.get_account_reasoning(coding_account)
            management_reasoning = self.security_mgr.get_account_reasoning(management_account)

            coding_config = ModelConfig(
                provider=coding_provider,
                model_name=coding_model,
                account_name=coding_account,
                reasoning_effort=None if coding_reasoning == 'default' else coding_reasoning
            )

            management_config = ModelConfig(
                provider=management_provider,
                model_name=management_model,
                account_name=management_account,
                reasoning_effort=None if management_reasoning == 'default' else management_reasoning
            )

            coding_timeout = get_coding_timeout(coding_provider)
            management_timeout = get_management_timeout(management_provider)

            # Create session log at start
            session_log_path = self._create_session_log(
                task_description=task_description,
                project_path=str(path),
                coding_account=coding_account,
                coding_provider=coding_provider,
                management_account=management_account,
                management_provider=management_provider,
                managed_mode=managed_mode
            )

            status_prefix = "**Starting Chad...**\n\n"
            status_prefix += f"• Project: {path}\n"
            status_prefix += f"• CODING: {coding_account} ({coding_provider})\n"
            if managed_mode:
                status_prefix += f"• MANAGEMENT: {management_account} ({management_provider})\n"
            status_prefix += f"• Mode: {'Managed (AI supervision)' if managed_mode else 'Direct (coding AI only)'}\n\n"

            initial_status = f"{status_prefix}⏳ Initializing sessions..."
            yield make_yield([], initial_status, summary=initial_status, interactive=False)

            # Activity callback to capture live updates
            # Format in Claude Code style: ● ToolName(params) with ⎿ for results
            def format_tool_activity(detail: str) -> str:
                """Format tool activity in Claude Code style."""
                # Handle Claude format: "ToolName: args"
                if ': ' in detail:
                    tool_name, args = detail.split(': ', 1)
                    return f"● {tool_name}({args})"
                # Handle Codex format: "Running: description"
                if detail.startswith('Running: '):
                    return f"● {detail[9:]}"
                return f"● {detail}"

            def on_activity(activity_type: str, detail: str):
                if activity_type == 'stream':
                    message_queue.put(('stream', detail))
                elif activity_type == 'tool':
                    formatted = format_tool_activity(detail)
                    message_queue.put(('activity', formatted))
                elif activity_type == 'thinking':
                    message_queue.put(('activity', f"⋯ {detail}"))
                elif activity_type == 'text' and detail:
                    message_queue.put(('activity', f"  ⎿ {detail[:80]}"))

            # Create session manager with silent mode enabled
            session_manager = SessionManager(coding_config, management_config, insane_mode=False, silent=True)
            self.session_manager = session_manager

            # ==================== DIRECT MODE (default) ====================
            if not managed_mode:
                yield make_yield([], f"{status_prefix}⏳ Starting coding AI...", summary=status_prefix)

                from .providers import create_provider
                coding_provider_instance = create_provider(coding_config)
                self._active_coding_provider = coding_provider_instance
                coding_provider_instance.set_activity_callback(on_activity)

                if not coding_provider_instance.start_session(str(path)):
                    failure = f"{status_prefix}❌ Failed to start coding session"
                    yield make_yield([], failure, summary=failure, interactive=True)
                    return

                status_msg = f"{status_prefix}✓ Coding AI started\n\n⏳ Processing task..."
                yield make_yield([], status_msg, summary=status_msg, interactive=False)

                coding_provider_instance.send_message(task_description)

                relay_complete = threading.Event()
                task_success = [False]
                completion_reason = [""]

                def direct_loop():
                    try:
                        message_queue.put(('ai_switch', 'CODING AI'))
                        message_queue.put(('message_start', 'CODING AI'))
                        response = coding_provider_instance.get_response(timeout=coding_timeout)
                        if response:
                            parsed = parse_codex_output(response)
                            message_queue.put(('message_complete', 'CODING AI', parsed))
                            task_success[0] = True
                            completion_reason[0] = "Coding AI completed task"
                        else:
                            message_queue.put(('status', "❌ No response from coding AI"))
                            completion_reason[0] = "No response from coding AI"
                    except Exception as e:
                        message_queue.put(('status', f"❌ Error: {str(e)}"))
                        completion_reason[0] = str(e)
                    finally:
                        coding_provider_instance.stop_session()
                        self._active_coding_provider = None
                        relay_complete.set()

                relay_thread = threading.Thread(target=direct_loop, daemon=True)
                relay_thread.start()

                self.session_manager = None  # No session manager in direct mode

            # ==================== MANAGED MODE ====================
            else:
                if not session_manager.start_sessions(str(path), task_description):
                    failure = f"{status_prefix}❌ Failed to start sessions"
                    yield make_yield([], failure, summary=failure, interactive=True)
                    return

                planning_status = f"{status_prefix}✓ Sessions started\n\n⏳ Management AI is planning..."
                yield make_yield([], planning_status, summary=planning_status, interactive=False)

                session_manager.set_activity_callback(on_activity)

                relay_complete = threading.Event()
                task_success = [False]
                completion_reason = [""]

                def relay_loop():  # noqa: C901
                    """Run the state machine: Investigation -> Implementation -> Verification."""
                    try:
                        phase = TaskPhase.INVESTIGATION
                        plan = None
                        impl_notes = []
                        investigation_revisits = 0
                        max_investigation_revisits = 2

                        max_investigation_iters = 10
                        max_implementation_iters = 30
                        max_verification_iters = 5

                        investigation_iter = 0
                        implementation_iter = 0
                        verification_iter = 0

                        def check_cancelled():
                            if self.cancel_requested:
                                message_queue.put(('status', "🛑 Task cancelled by user"))
                                return True
                            return False

                        def get_phase_status(p: TaskPhase) -> str:
                            phase_names = {
                                TaskPhase.INVESTIGATION: ("📋", "Investigate"),
                                TaskPhase.IMPLEMENTATION: ("🔨", "Implement"),
                                TaskPhase.VERIFICATION: ("✅", "Verify")
                            }
                            icon, name = phase_names[p]
                            return f"{icon} Phase {p.value}: {name}"

                        def add_phase_divider(phase: TaskPhase):
                            """Add a visual divider when entering a new phase."""
                            phase_names = {
                                TaskPhase.INVESTIGATION: ("📋", "INVESTIGATE"),
                                TaskPhase.IMPLEMENTATION: ("🔨", "IMPLEMENT"),
                                TaskPhase.VERIFICATION: ("✅", "VERIFY")
                            }
                            icon, name = phase_names[phase]
                            message_queue.put(('phase_divider', f"{icon} PHASE {phase.value}: {name}"))

                        investigation_system = INVESTIGATION_PROMPT.format(
                            task_description=task_description,
                            project_path=str(path)
                        )
                        investigation_system += "\n\n" + SAFETY_CONSTRAINTS

                        add_phase_divider(phase)

                        message_queue.put(('status', f"{get_phase_status(phase)} - Starting..."))
                        message_queue.put(('ai_switch', 'MANAGEMENT AI'))
                        message_queue.put(('message_start', 'MANAGEMENT AI'))
                        session_manager.send_to_management(investigation_system)
                        mgmt_response = session_manager.get_management_response(timeout=management_timeout)

                        if check_cancelled() or not mgmt_response:
                            if not mgmt_response:
                                message_queue.put(('status', "❌ No response from MANAGEMENT AI"))
                            return

                        mgmt_text = extract_final_codex_response(mgmt_response)
                        parsed_mgmt = parse_codex_output(mgmt_response) or mgmt_text
                        message_queue.put(('message_complete', "MANAGEMENT AI", parsed_mgmt))

                        while session_manager.are_sessions_alive() and not self.cancel_requested:

                            if phase == TaskPhase.INVESTIGATION:
                                investigation_iter += 1

                                if investigation_iter > max_investigation_iters:
                                    message_queue.put(('status', "⚠️ Investigation taking too long, forcing plan"))
                                    plan = f"1. Implement the task: {task_description}"
                                    phase = TaskPhase.IMPLEMENTATION
                                    continue

                                plan_match = re.search(r'PLAN:\s*(.+)', mgmt_text, re.IGNORECASE | re.DOTALL)

                                if plan_match:
                                    plan = plan_match.group(1).strip()
                                    phase = TaskPhase.IMPLEMENTATION
                                    implementation_iter = 0

                                    add_phase_divider(phase)
                                    message_queue.put(('phase', f"📝 **Plan:**\n{plan}"))

                                    impl_system = IMPLEMENTATION_PROMPT.format(
                                        task_description=task_description,
                                        project_path=str(path),
                                        plan=plan
                                    )
                                    impl_system += "\n\n" + SAFETY_CONSTRAINTS

                                    message_queue.put(
                                        ('status', f"{get_phase_status(phase)} - Coding AI executing...")
                                    )
                                    coding_instruction = (
                                        f"{CODING_IMPLEMENTATION_CONTEXT}\n\n"
                                        f"ORIGINAL TASK: {task_description}\n\n"
                                        f"PLAN TO EXECUTE:\n{plan}\n\n"
                                        "Execute this plan to accomplish the task. Report back when complete."
                                    )
                                    session_manager.send_to_coding(coding_instruction)
                                    continue

                                message_queue.put(('status', f"{get_phase_status(phase)} - Coding AI investigating..."))
                                message_queue.put(('ai_switch', 'CODING AI'))
                                message_queue.put(('message_start', 'CODING AI'))
                                session_manager.send_to_coding(f"{CODING_INVESTIGATION_CONTEXT}\n\n{mgmt_text}")

                                coding_response = session_manager.get_coding_response(timeout=coding_timeout)
                                if check_cancelled() or not coding_response:
                                    if not coding_response:
                                        message_queue.put(('status', "❌ No response from CODING AI"))
                                    break

                                parsed_coding = parse_codex_output(coding_response)
                                message_queue.put(('message_complete', "CODING AI", parsed_coding))

                                status_msg = f"{get_phase_status(phase)} - Management reviewing findings..."
                                message_queue.put(('status', status_msg))
                                message_queue.put(('ai_switch', 'MANAGEMENT AI'))
                                message_queue.put(('message_start', 'MANAGEMENT AI'))
                                findings_msg = (
                                    f"CODING AI FINDINGS:\n{parsed_coding}\n\n"
                                    "If you have enough info, output PLAN: with implementation steps. "
                                    "If not, ask for MORE info in ONE batched request "
                                    "(multiple files/searches at once)."
                                )
                                session_manager.send_to_management(findings_msg)
                                mgmt_response = session_manager.get_management_response(
                                    timeout=management_timeout
                                )

                                if check_cancelled() or not mgmt_response:
                                    if not mgmt_response:
                                        message_queue.put(('status', "❌ No response from MANAGEMENT AI"))
                                    break

                                mgmt_text = extract_final_codex_response(mgmt_response)
                                parsed_mgmt = parse_codex_output(mgmt_response) or mgmt_text
                                message_queue.put(('message_complete', "MANAGEMENT AI", parsed_mgmt))

                            elif phase == TaskPhase.IMPLEMENTATION:
                                implementation_iter += 1

                                if implementation_iter > max_implementation_iters:
                                    completion_reason[0] = "Reached maximum implementation iterations."
                                    message_queue.put(('status', "⚠️ Implementation taking too long"))
                                    break

                                message_queue.put(('status', f"{get_phase_status(phase)} - Coding AI working..."))
                                message_queue.put(('ai_switch', 'CODING AI'))
                                message_queue.put(('message_start', 'CODING AI'))
                                coding_response = session_manager.get_coding_response(timeout=coding_timeout)

                                if check_cancelled() or not coding_response:
                                    if not coding_response:
                                        message_queue.put(('status', "❌ No response from CODING AI"))
                                    break

                                parsed_coding = parse_codex_output(coding_response)
                                message_queue.put(('message_complete', "CODING AI", parsed_coding))
                                impl_notes.append(parsed_coding[:10000])

                                status_msg = f"{get_phase_status(phase)} - Management supervising..."
                                message_queue.put(('status', status_msg))
                                message_queue.put(('ai_switch', 'MANAGEMENT AI'))
                                message_queue.put(('message_start', 'MANAGEMENT AI'))
                                supervision_msg = (
                                    f"CODING AI OUTPUT:\n{parsed_coding}\n\n"
                                    "Respond with CONTINUE: <guidance for remaining steps> or VERIFY "
                                    "if complete. Be direct - give ALL remaining instructions at once, "
                                    "not one step at a time."
                                )
                                session_manager.send_to_management(supervision_msg)
                                mgmt_response = session_manager.get_management_response(
                                    timeout=management_timeout
                                )

                                if check_cancelled() or not mgmt_response:
                                    if not mgmt_response:
                                        message_queue.put(('status', "❌ No response from MANAGEMENT AI"))
                                    break

                                mgmt_text = extract_final_codex_response(mgmt_response)
                                parsed_mgmt = parse_codex_output(mgmt_response) or mgmt_text
                                message_queue.put(('message_complete', "MANAGEMENT AI", parsed_mgmt))

                                mgmt_upper = mgmt_text.upper()
                                if "VERIFY" in mgmt_upper:
                                    phase = TaskPhase.VERIFICATION
                                    verification_iter = 0

                                    add_phase_divider(phase)

                                    impl_summary = "\n".join(impl_notes[-5:])
                                    verify_system = VERIFICATION_PROMPT.format(
                                        task_description=task_description,
                                        project_path=str(path),
                                        plan=plan,
                                        impl_notes=impl_summary
                                    )
                                    verify_system += "\n\n" + SAFETY_CONSTRAINTS

                                    status_msg = f"{get_phase_status(phase)} - Management verifying..."
                                    message_queue.put(('status', status_msg))
                                    message_queue.put(('message_start', 'MANAGEMENT AI'))
                                    session_manager.send_to_management(verify_system)
                                    mgmt_response = session_manager.get_management_response(
                                        timeout=management_timeout
                                    )

                                    if check_cancelled() or not mgmt_response:
                                        if not mgmt_response:
                                            message_queue.put(('status', "❌ No response from MANAGEMENT AI"))
                                        break

                                    mgmt_text = extract_final_codex_response(mgmt_response)
                                    parsed_mgmt = parse_codex_output(mgmt_response) or mgmt_text
                                    message_queue.put(('message_complete', "MANAGEMENT AI", parsed_mgmt))
                                    continue

                                message_queue.put(('ai_switch', 'CODING AI'))
                                continue_match = re.search(r'CONTINUE:\s*(.+)', mgmt_text, re.IGNORECASE | re.DOTALL)
                                if continue_match:
                                    guidance = continue_match.group(1).strip()
                                    session_manager.send_to_coding(f"{CODING_IMPLEMENTATION_CONTEXT}\n\n{guidance}")
                                else:
                                    session_manager.send_to_coding(f"{CODING_IMPLEMENTATION_CONTEXT}\n\n{mgmt_text}")

                            elif phase == TaskPhase.VERIFICATION:
                                verification_iter += 1

                                if verification_iter > max_verification_iters:
                                    completion_reason[0] = "Verification inconclusive after maximum attempts."
                                    message_queue.put(('status', "⚠️ Verification inconclusive"))
                                    break

                                mgmt_upper = mgmt_text.upper()
                                is_complete = ("COMPLETE" in mgmt_upper and
                                               "PLAN_ISSUE" not in mgmt_upper and
                                               "IMPL_ISSUE" not in mgmt_upper)
                                if is_complete:
                                    completion_reason[0] = "Management AI verified task completion."
                                    message_queue.put(('status', "✓ Task verified complete!"))
                                    task_success[0] = True
                                    break

                                plan_issue_match = re.search(
                                    r'PLAN_ISSUE:\s*(.+)', mgmt_text, re.IGNORECASE | re.DOTALL)
                                if plan_issue_match:
                                    investigation_revisits += 1
                                    if investigation_revisits > max_investigation_revisits:
                                        completion_reason[0] = "Too many plan revisions needed."
                                        message_queue.put(('status', "⚠️ Too many plan issues"))
                                        break

                                    issue = plan_issue_match.group(1).strip().split('\n')[0]
                                    phase = TaskPhase.INVESTIGATION
                                    investigation_iter = 0

                                    add_phase_divider(phase)
                                    message_queue.put(('phase', f"⚠️ Plan issue: {issue}"))

                                    reinvestigate_prompt = f"""The previous plan had issues: {issue}

{investigation_system}

Previous plan that failed:
{plan}

Create a better plan that addresses the issue."""
                                    message_queue.put(('message_start', 'MANAGEMENT AI'))
                                    session_manager.send_to_management(reinvestigate_prompt)
                                    mgmt_response = session_manager.get_management_response(timeout=management_timeout)

                                    if check_cancelled() or not mgmt_response:
                                        break

                                    mgmt_text = extract_final_codex_response(mgmt_response)
                                    parsed_mgmt = parse_codex_output(mgmt_response) or mgmt_text
                                    message_queue.put(('message_complete', "MANAGEMENT AI", parsed_mgmt))
                                    continue

                                impl_issue_match = re.search(
                                    r'IMPL_ISSUE:\s*(.+)', mgmt_text, re.IGNORECASE | re.DOTALL)
                                if impl_issue_match:
                                    issue = impl_issue_match.group(1).strip()
                                    phase = TaskPhase.IMPLEMENTATION

                                    add_phase_divider(phase)
                                    phase_msg = f"⚠️ Implementation issue: {issue[:100]}..."
                                    message_queue.put(('phase', phase_msg))

                                    message_queue.put(('ai_switch', 'CODING AI'))
                                    fix_instruction = (
                                        f"{CODING_IMPLEMENTATION_CONTEXT}\n\n"
                                        f"VERIFICATION FOUND AN ISSUE:\n{issue}\n\n"
                                        "Please fix this and report back."
                                    )
                                    session_manager.send_to_coding(fix_instruction)
                                    continue

                                completion_phrases = [
                                    'task is complete', 'task completed', 'successfully completed',
                                    'has been completed', 'is done', 'task done', 'confirmed complete',
                                    'yes, complete', 'verified complete', 'fulfills the requirement',
                                    'fulfill the original task', 'satisfies the requirement'
                                ]
                                if any(phrase in mgmt_text.lower() for phrase in completion_phrases):
                                    completion_reason[0] = "Management AI confirmed task completion."
                                    message_queue.put(('status', "✓ Task verified complete!"))
                                    task_success[0] = True
                                    break

                                if verification_iter >= 3:
                                    completion_reason[0] = "Task appears complete (verification loop limit reached)."
                                    message_queue.put(('status', "✓ Task complete (auto-verified)"))
                                    task_success[0] = True
                                    break

                                message_queue.put(('message_start', 'MANAGEMENT AI'))
                                verdict_prompt = (
                                    "Output your verdict: COMPLETE, PLAN_ISSUE: <reason>, "
                                    "or IMPL_ISSUE: <reason>"
                                )
                                session_manager.send_to_management(verdict_prompt)
                                mgmt_response = session_manager.get_management_response(
                                    timeout=management_timeout
                                )

                                if check_cancelled() or not mgmt_response:
                                    break

                                mgmt_text = extract_final_codex_response(mgmt_response)
                                parsed_mgmt = parse_codex_output(mgmt_response) or mgmt_text
                                message_queue.put(('message_complete', "MANAGEMENT AI", parsed_mgmt))

                    except Exception as e:
                        message_queue.put(('status', f"❌ Error: {str(e)}"))
                    finally:
                        session_manager.stop_all()
                        relay_complete.set()

                relay_thread = threading.Thread(target=relay_loop, daemon=True)
                relay_thread.start()

            # Stream updates with live activity
            if managed_mode:
                current_status = f"{status_prefix}⏳ Management AI is planning..."
                current_ai = "MANAGEMENT AI"
            else:
                current_status = f"{status_prefix}⏳ Coding AI is working..."
                current_ai = "CODING AI"
            current_live_stream = ""
            yield make_yield(
                chat_history, current_status, current_live_stream, summary=current_status, interactive=False
            )

            import time as time_module
            last_activity = ""
            streaming_buffer = ""
            full_history = []  # Infinite history - list of (ai_name, content) tuples
            last_yield_time = 0.0
            min_yield_interval = 0.05

            def format_live_output(ai_name: str, content: str) -> str:
                """Format live output with header showing active AI."""
                if not content.strip():
                    return ""
                # Convert ANSI codes to HTML for native terminal colors
                html_content = ansi_to_html(content)
                header = f'<div class="live-output-header">▶ {ai_name} (Live Stream)</div>'
                body = f'<div class="live-output-content">{html_content}</div>'
                return f'{header}\n{body}'

            def get_display_content() -> str:
                """Build display content from full history, showing last portion."""
                if not full_history:
                    return ""
                # Combine all history entries
                combined = []
                for ai_name, chunk in full_history:
                    combined.append(chunk)
                full_content = ''.join(combined)
                # Show last portion for display performance (but history is preserved)
                if len(full_content) > 50000:
                    return full_content[-50000:]
                return full_content

            pending_message_idx = None

            while not relay_complete.is_set() and not self.cancel_requested:
                try:
                    msg = message_queue.get(timeout=0.02)
                    msg_type = msg[0]

                    if msg_type == 'message':
                        speaker, content = msg[1], msg[2]
                        chat_history.append(make_chat_message(speaker, content))
                        streaming_buffer = ""
                        last_activity = ""
                        current_live_stream = ""
                        yield make_yield(chat_history, current_status, current_live_stream)
                        last_yield_time = time_module.time()

                    elif msg_type == 'message_start':
                        speaker = msg[1]
                        placeholder = {"role": "user" if "MANAGEMENT" in speaker else "assistant",
                                       "content": f"**{speaker}**\n\n⏳ *Working...*"}
                        chat_history.append(placeholder)
                        pending_message_idx = len(chat_history) - 1
                        streaming_buffer = ""
                        last_activity = ""
                        current_live_stream = ""
                        yield make_yield(chat_history, current_status, current_live_stream)
                        last_yield_time = time_module.time()

                    elif msg_type == 'message_complete':
                        speaker, content = msg[1], msg[2]
                        if pending_message_idx is not None and pending_message_idx < len(chat_history):
                            chat_history[pending_message_idx] = make_chat_message(speaker, content)
                        else:
                            chat_history.append(make_chat_message(speaker, content))
                        pending_message_idx = None
                        streaming_buffer = ""
                        last_activity = ""
                        current_live_stream = ""
                        self._update_session_log(session_log_path, chat_history)
                        yield make_yield(chat_history, current_status, current_live_stream)
                        last_yield_time = time_module.time()

                    elif msg_type == 'status':
                        current_status = f"{status_prefix}{msg[1]}"
                        streaming_buffer = ""
                        current_live_stream = ""
                        summary_text = current_status
                        yield make_yield(chat_history, current_status, current_live_stream, summary=summary_text)
                        last_yield_time = time_module.time()

                    elif msg_type == 'phase_divider':
                        phase_name = msg[1]
                        divider = f"───────────── {phase_name} ─────────────"
                        chat_history.append({"role": "user", "content": divider})
                        streaming_buffer = ""
                        current_live_stream = ""
                        self._update_session_log(session_log_path, chat_history)
                        yield make_yield(chat_history, current_status, current_live_stream)
                        last_yield_time = time_module.time()

                    elif msg_type == 'phase':
                        phase_msg = msg[1]
                        chat_history.append({"role": "user", "content": phase_msg})
                        streaming_buffer = ""
                        current_live_stream = ""
                        summary_text = f"{status_prefix}{phase_msg}"
                        self._update_session_log(session_log_path, chat_history)
                        yield make_yield(chat_history, current_status, current_live_stream, summary=summary_text)
                        last_yield_time = time_module.time()

                    elif msg_type == 'ai_switch':
                        current_ai = msg[1]
                        streaming_buffer = ""
                        # Add separator to history for AI switch
                        full_history.append((current_ai, f"\n--- {current_ai} ---\n"))

                    elif msg_type == 'stream':
                        streaming_buffer += msg[1]
                        # Add to infinite history (no truncation)
                        full_history.append((current_ai, msg[1]))
                        now = time_module.time()
                        if now - last_yield_time >= min_yield_interval:
                            # Get display content from full history
                            display_content = get_display_content()
                            current_live_stream = format_live_output(
                                current_ai, display_content
                            )
                            yield make_yield(
                                chat_history, current_status, current_live_stream
                            )
                            last_yield_time = now

                    elif msg_type == 'activity':
                        last_activity = msg[1]
                        now = time_module.time()
                        if now - last_yield_time >= min_yield_interval:
                            display_content = get_display_content()
                            if display_content:
                                content = display_content + f"\n\n{last_activity}"
                                current_live_stream = format_live_output(
                                    current_ai, content
                                )
                            else:
                                current_live_stream = f"**Live:** {last_activity}"
                            yield make_yield(
                                chat_history, current_status, current_live_stream
                            )
                            last_yield_time = now

                except queue.Empty:
                    now = time_module.time()
                    if now - last_yield_time >= 0.3:
                        display_content = get_display_content()
                        if display_content:
                            current_live_stream = format_live_output(
                                current_ai, display_content
                            )
                        elif last_activity:
                            current_live_stream = f"**Live:** {last_activity}"
                        yield make_yield(
                            chat_history, current_status, current_live_stream
                        )
                        last_yield_time = now

            # If cancelled, don't process remaining messages - just mark as cancelled
            if self.cancel_requested:
                # Find the last assistant message and mark it as cancelled
                for idx in range(len(chat_history) - 1, -1, -1):
                    msg = chat_history[idx]
                    if isinstance(msg, dict) and msg.get("role") == "assistant":
                        chat_history[idx] = {
                            "role": "assistant",
                            "content": "**CODING AI**\n\n🛑 *Cancelled*"
                        }
                        break
                yield make_yield(chat_history, "🛑 Task cancelled", "", summary="🛑 Task cancelled")
            else:
                # Process any remaining message_complete messages
                while True:
                    try:
                        msg = message_queue.get_nowait()
                        msg_type = msg[0]
                        if msg_type == 'message_complete':
                            speaker, content = msg[1], msg[2]
                            if pending_message_idx is not None and pending_message_idx < len(chat_history):
                                chat_history[pending_message_idx] = make_chat_message(speaker, content)
                            else:
                                chat_history.append(make_chat_message(speaker, content))
                            self._update_session_log(session_log_path, chat_history)
                            yield make_yield(chat_history, current_status, "")
                    except queue.Empty:
                        break

            relay_thread.join(timeout=1)
            if self.cancel_requested:
                final_status = "🛑 Task cancelled by user"
            elif task_success[0]:
                if completion_reason[0]:
                    final_status = f"✓ Task completed!\n\n*{completion_reason[0]}*"
                else:
                    final_status = "✓ Task completed!"
            else:
                final_status = (
                    f"❌ Task did not complete successfully\n\n*{completion_reason[0]}*"
                    if completion_reason[0]
                    else "❌ Task did not complete successfully"
                )

            self._update_session_log(
                session_log_path,
                chat_history,
                success=task_success[0],
                completion_reason=completion_reason[0],
                status="completed" if task_success[0] else "failed"
            )
            final_status += f"\n\n*Session log: {session_log_path}*"
            final_summary = f"{status_prefix}{final_status}"

            yield make_yield(chat_history, final_summary, "", summary=final_summary, interactive=True)

        except Exception as e:
            import traceback
            error_msg = f"❌ Error: {str(e)}\n\n```\n{traceback.format_exc()}\n```"
            yield make_yield(chat_history, error_msg, summary=error_msg, interactive=True)

    def _filter_messages_by_task(self, all_messages: list, selected_tab: str) -> list:
        """Filter chat messages by task tab selection (single task currently)."""
        if not all_messages:
            return []
        if not selected_tab or selected_tab == "All":
            return all_messages
        return [
            msg for msg in all_messages
            if isinstance(msg, dict) and msg.get("task_id") == selected_tab
        ]

    def _get_task_status_header(self, selected_tab: str | None = None) -> str:
        """Get the current task status header for the selected tab."""
        return self._last_status_header or ""

    def create_interface(self) -> gr.Blocks:
        """Create the Gradio interface."""
        with gr.Blocks(title="Chad") as interface:
            # Inject custom CSS and JavaScript
            gr.HTML(f"<style>{PROVIDER_PANEL_CSS}</style>{LIVE_STREAM_JS}")

            with gr.Tabs():
                # Run Task Tab (default)
                with gr.Tab("🚀 Run Task"):
                    gr.Markdown("## Start a New Task")

                    # Check initial role configuration
                    is_ready, config_status = self.get_role_config_status()

                    with gr.Row():
                        with gr.Column():
                            # Allow override via env var (for screenshots)
                            default_path = os.environ.get('CHAD_PROJECT_PATH', str(Path.cwd()))
                            project_path = gr.Textbox(
                                label="Project Path",
                                placeholder="/path/to/project",
                                value=default_path
                            )
                            task_description = gr.TextArea(
                                label="Task Description",
                                placeholder="Describe what you want done...",
                                lines=5
                            )
                            # Show role configuration status
                            role_status = gr.Markdown(config_status, elem_id="role-config-status")
                            with gr.Row():
                                start_btn = gr.Button(
                                    "Start Task",
                                    variant="primary",
                                    interactive=is_ready,
                                    elem_id="start-task-btn"
                                )
                                cancel_btn = gr.Button(
                                    "🛑 Cancel",
                                    variant="stop",
                                    elem_id="cancel-task-btn"
                                )

                    # Radio button tabs for filtering by task
                    subtask_tabs = gr.Radio(
                        choices=["All"],
                        value="All",
                        label="View",
                        visible=False,
                        elem_id="subtask-tabs"
                    )

                    # Planning view - shows model assignments and subtask status
                    subtask_list = gr.Markdown(
                        "",
                        elem_id="subtask-list",
                        visible=False
                    )

                    # Task status header (shows selected task description and status)
                    task_status_header = gr.Markdown("", elem_id="task-status-header", visible=False)

                    # State to store all messages with task IDs for filtering
                    all_messages_state = gr.State([])

                    # Agent communication view
                    with gr.Row():
                        with gr.Column():
                            chatbot = gr.Chatbot(
                                label="Agent Communication",
                                height=400,
                                elem_id="agent-chatbot",
                                autoscroll=False
                            )

                    # Live activity stream
                    live_stream_box = gr.Markdown("", elem_id="live-stream-box")

                # Providers Tab (combined management + usage)
                with gr.Tab("⚙️ Providers"):
                    account_items = list(self.security_mgr.list_accounts().items())
                    # Allow room for new providers without needing a reload
                    self.provider_card_count = max(12, len(account_items) + 8)

                    provider_feedback = gr.Markdown("")
                    gr.Markdown("### Providers", elem_classes=["provider-section-title"])

                    provider_list = gr.Markdown(self.list_providers(), elem_classes=["provider-summary"])
                    refresh_btn = gr.Button("🔄 Refresh", variant="secondary")
                    delete_confirm_state = gr.State(False)

                    provider_cards = []
                    for idx in range(self.provider_card_count):
                        if idx < len(account_items):
                            account_name, provider_type = account_items[idx]
                            visible = True
                            header_text = (
                                f'<span class="provider-card__header-text">'
                                f'{account_name} ({provider_type})</span>'
                            )
                            role_value = self._get_account_role(account_name) or "(none)"
                            model_choices = self.get_models_for_account(account_name)
                            stored_model = self.security_mgr.get_account_model(account_name)
                            model_value = stored_model if stored_model in model_choices else model_choices[0]
                            reasoning_choices = self.get_reasoning_choices(provider_type, account_name)
                            stored_reasoning = self.security_mgr.get_account_reasoning(account_name)
                            reasoning_value = stored_reasoning if stored_reasoning in reasoning_choices else reasoning_choices[0]
                            usage_text = self.get_provider_usage(account_name)
                        else:
                            account_name = ""
                            visible = False
                            header_text = ""
                            role_value = "(none)"
                            model_choices = ["default"]
                            model_value = "default"
                            reasoning_choices = ["default"]
                            reasoning_value = "default"
                            usage_text = ""

                        with gr.Group(visible=visible, elem_classes=["provider-card"]) as card_group:
                            with gr.Row(elem_classes=["provider-card__header-row"]):
                                card_header = gr.Markdown(header_text, elem_classes=["provider-card__header"])
                                delete_btn = gr.Button("🗑︎", variant="secondary", size="sm", min_width=0, scale=0, elem_classes=["provider-delete"])  # noqa: E501
                            account_state = gr.State(account_name)
                            with gr.Row(elem_classes=["provider-controls"]):
                                role_dropdown = gr.Dropdown(
                                    choices=["(none)", "CODING", "MANAGEMENT", "BOTH"],
                                    label="Role",
                                    value=role_value,
                                    scale=1
                                )
                                model_dropdown = gr.Dropdown(
                                    choices=model_choices,
                                    label="Preferred Model",
                                    value=model_value,
                                    allow_custom_value=True,
                                    scale=1
                                )
                                reasoning_dropdown = gr.Dropdown(
                                    choices=reasoning_choices,
                                    label="Reasoning Effort",
                                    value=reasoning_value,
                                    allow_custom_value=True,
                                    scale=1
                                )

                            gr.Markdown("Usage", elem_classes=["provider-usage-title"])
                            usage_box = gr.Markdown(usage_text, elem_classes=["provider-usage"])

                        provider_cards.append({
                            "group": card_group,
                            "header": card_header,
                            "account_state": account_state,
                            "account_name": account_name,  # Store name for delete handler
                            "role_dropdown": role_dropdown,
                            "model_dropdown": model_dropdown,
                            "reasoning_dropdown": reasoning_dropdown,
                            "usage_box": usage_box,
                            "delete_btn": delete_btn
                        })

                    with gr.Accordion("Add New Provider", open=False) as add_provider_accordion:
                        gr.Markdown("Click to add another provider. Close the accordion to retract without adding.")
                        new_provider_name = gr.Textbox(
                            label="Provider Name",
                            placeholder="e.g., work-claude"
                        )
                        new_provider_type = gr.Dropdown(
                            choices=["anthropic", "openai", "gemini", "mistral"],
                            label="Provider Type",
                            value="anthropic"
                        )
                        add_btn = gr.Button("Add Provider", variant="primary", interactive=False)

                    provider_outputs = [provider_feedback, provider_list]
                    for card in provider_cards:
                        provider_outputs.extend([
                            card["group"],
                            card["header"],
                            card["account_state"],
                            card["role_dropdown"],
                            card["model_dropdown"],
                            card["reasoning_dropdown"],
                            card["usage_box"]
                        ])

                    # Add role status and start button to outputs so they update when roles change
                    provider_outputs_with_task_status = provider_outputs + [role_status, start_btn]

                    add_provider_outputs = provider_outputs + [new_provider_name, add_btn, add_provider_accordion]

                    def refresh_with_task_status():
                        base = self._provider_action_response("")
                        is_ready, config_msg = self.get_role_config_status()
                        return (*base, config_msg, gr.update(interactive=is_ready))

                    refresh_btn.click(
                        refresh_with_task_status,
                        outputs=provider_outputs_with_task_status
                    )

                    new_provider_name.change(
                        lambda name: gr.update(interactive=bool(name.strip())),
                        inputs=[new_provider_name],
                        outputs=[add_btn]
                    )

                    add_btn.click(
                        self.add_provider,
                        inputs=[new_provider_name, new_provider_type],
                        outputs=add_provider_outputs
                    )

                    def assign_role_with_task_status(account_name, role):
                        base = self.assign_role(account_name, role)
                        is_ready, config_msg = self.get_role_config_status()
                        return (*base, config_msg, gr.update(interactive=is_ready))

                    for card in provider_cards:
                        card["role_dropdown"].change(
                            assign_role_with_task_status,
                            inputs=[card["account_state"], card["role_dropdown"]],
                            outputs=provider_outputs_with_task_status
                        )

                        card["model_dropdown"].change(
                            self.set_model,
                            inputs=[card["account_state"], card["model_dropdown"]],
                            outputs=provider_outputs
                        )
                        card["reasoning_dropdown"].change(
                            self.set_reasoning,
                            inputs=[card["account_state"], card["reasoning_dropdown"]],
                            outputs=provider_outputs
                        )

                        # Get the stored account name for this specific card
                        card_account_name = card["account_name"]

                        # Create delete handler with account name baked into closure
                        def make_delete_handler(acc_name):
                            def handler(confirmed):
                                return self.delete_provider(acc_name, confirmed)
                            return handler

                        # Skip empty cards (no account)
                        if not card_account_name:
                            continue

                        # Use JS to show confirmation, then call Python handler with result
                        card["delete_btn"].click(
                            fn=None,
                            inputs=[],
                            outputs=[delete_confirm_state],
                            js=f"() => window.confirm('Please confirm you want to delete {card_account_name}')"
                        ).then(
                            fn=make_delete_handler(card_account_name),
                            inputs=[delete_confirm_state],
                            outputs=provider_outputs
                        )

            # Connect task execution (outside tabs)
            start_btn.click(
                self.start_chad_task,
                inputs=[project_path, task_description],
                outputs=[chatbot, live_stream_box, subtask_list, subtask_tabs, task_status_header, all_messages_state, project_path, task_description, start_btn, cancel_btn]  # noqa: E501
            )

            # Handle tab changes to filter messages
            def on_tab_change(selected_tab, all_messages):
                filtered = self._filter_messages_by_task(all_messages, selected_tab)
                header = self._get_task_status_header(selected_tab)
                return filtered, gr.update(value=header, visible=bool(header))

            subtask_tabs.change(
                on_tab_change,
                inputs=[subtask_tabs, all_messages_state],
                outputs=[chatbot, task_status_header]
            )

            cancel_btn.click(
                self.cancel_task,
                outputs=[live_stream_box]
            )

            return interface


def launch_web_ui(password: str = None, port: int = 7860) -> tuple[None, int]:
    """Launch the Chad web interface.

    Args:
        password: Main password. If not provided, will prompt via CLI
        port: Port to run on. Use 0 for ephemeral port.

    Returns:
        Tuple of (None, actual_port) where actual_port is the port used
    """
    security_mgr = SecurityManager()

    # Get or verify password
    if security_mgr.is_first_run():
        if password:
            # Setup with provided password
            import bcrypt
            import base64
            password_hash = security_mgr.hash_password(password)
            encryption_salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
            config = {
                'password_hash': password_hash,
                'encryption_salt': encryption_salt,
                'accounts': {}
            }
            security_mgr.save_config(config)
            main_password = password
        else:
            main_password = security_mgr.setup_main_password()
    else:
        if password is not None:
            # Use provided password (for automation/screenshots)
            main_password = password
        else:
            # Interactive mode - verify password which includes the reset flow
            main_password = security_mgr.verify_main_password()

    # Create and launch UI
    ui = ChadWebUI(security_mgr, main_password)
    app = ui.create_interface()

    # Find a free port if ephemeral requested
    ephemeral = port == 0
    if ephemeral:
        import socket
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(('127.0.0.1', 0))
            port = s.getsockname()[1]

    print("\n" + "=" * 70)
    print("CHAD WEB UI")
    print("=" * 70)
    if not ephemeral:
        print("Opening web interface in your browser...")
    print("Press Ctrl+C to stop the server")
    print("=" * 70 + "\n")

    # Print port marker for scripts to parse (before launch blocks)
    print(f"CHAD_PORT={port}", flush=True)

    app.launch(
        server_name="127.0.0.1",
        server_port=port,
        share=False,
        inbrowser=not ephemeral,  # Don't open browser for screenshot mode
        quiet=False
    )

    return None, port
