import os
from typing import Dict, Optional, Tuple

from PySide6.QtCore import Slot, Qt, QPoint
from PySide6.QtWidgets import QApplication, QListWidget, QListWidgetItem, QFrame
from PySide6.QtGui import QTextCursor, QFont

from langchain_core.messages.utils import count_tokens_approximately

from airunner.components.chat.gui.widgets.templates.chat_prompt_ui import (
    Ui_chat_prompt,
)
from airunner.components.llm.data.conversation import Conversation
from airunner.enums import (
    SignalCode,
    LLMActionType,
    ModelType,
    ModelStatus,
    ModelService,
)
from airunner.components.application.gui.widgets.base_widget import BaseWidget
from airunner.components.conversations.conversation_history_manager import (
    ConversationHistoryManager,
)
from airunner.utils.application import create_worker, get_logger
from airunner.components.llm.managers.llm_request import LLMRequest
from airunner.components.llm.workers.llm_response_worker import (
    LLMResponseWorker,
)
from airunner.settings import (
    AIRUNNER_ART_ENABLED,
    AIRUNNER_LOG_LEVEL,
    SLASH_COMMANDS,
)
from airunner.components.llm.config.provider_config import LLMProviderConfig


class ChatPromptWidget(BaseWidget):
    widget_class_ = Ui_chat_prompt
    icons = [
        ("chevron-up", "send_button"),
        ("plus", "clear_conversation_button"),
        ("clock", "history_button"),
        ("settings", "settings_button"),
        ("stop-circle", "stop_button"),
    ]
    logger = get_logger(__name__, AIRUNNER_LOG_LEVEL)

    def __init__(self, *args, **kwargs):
        self.signal_handlers = {
            SignalCode.AUDIO_PROCESSOR_RESPONSE_SIGNAL: self.on_hear_signal,
            SignalCode.MODEL_STATUS_CHANGED_SIGNAL: self.on_model_status_changed_signal,
            SignalCode.LLM_TEXT_STREAMED_SIGNAL: self.on_add_bot_message_to_conversation,
            SignalCode.LLM_TEXT_GENERATE_REQUEST_SIGNAL: self.on_llm_text_generate_request_signal,
            SignalCode.LLM_MODEL_CHANGED: self.on_llm_model_changed,
            SignalCode.APPLICATION_SETTINGS_CHANGED_SIGNAL: self.on_application_settings_changed,
        }
        self._splitters = ["chat_prompt_splitter"]
        self._default_splitter_settings_applied = False
        super().__init__()
        self._highlighted = False
        self.registered: bool = False
        self.scroll_bar = None
        self.is_modal = True
        self.generating = False
        self.prefix = ""
        self.prompt = ""
        self.suffix = ""
        self.spacer = None
        self.promptKeyPressEvent = None
        self.originalKeyPressEvent = None
        self.action_menu_displayed = None
        self.action_menu_displayed = None
        self.messages_spacer = None
        self.chat_loaded = False
        self._model_context_tokens: Optional[int] = None
        
        # Token tracking for sent/received messages
        self._tokens_sent_last: int = 0
        self._tokens_received_last: int = 0
        self._tokens_sent_total: int = 0
        self._tokens_received_total: int = 0
        self._current_response_tokens: int = 0  # Accumulator for streaming response
        
        # Initialize provider dropdown
        self._populate_provider_dropdown()
        
        # Initialize model dropdown
        self._populate_model_dropdown()
        
        # Connect model dropdown edit signal for custom model entry
        if hasattr(self.ui, "model_dropdown"):
            self.ui.model_dropdown.lineEdit().returnPressed.connect(self._on_custom_model_entered)
        
        # Hardcode action to AUTO - we don't use the dropdown anymore
        self.update_llm_generator_settings(action=LLMActionType.APPLICATION_COMMAND.name)
        
        # Initialize thinking checkbox from settings
        if hasattr(self.ui, "thinking_checkbox"):
            self.ui.thinking_checkbox.blockSignals(True)
            enable_thinking = getattr(self.llm_generator_settings, "enable_thinking", True)
            # Handle None value from database (default to True)
            if enable_thinking is None:
                enable_thinking = True
            self.ui.thinking_checkbox.setChecked(enable_thinking)
            self.ui.thinking_checkbox.blockSignals(False)
            # Update visibility based on whether model supports thinking
            self._update_thinking_checkbox_visibility()
        
        self.originalKeyPressEvent = self.ui.prompt.keyPressEvent
        self.ui.prompt.keyPressEvent = self.handle_key_press
        
        self.held_message = None
        self._disabled = False
        self.scroll_animation = None
        
        # Setup slash command autocomplete
        self._setup_slash_command_completer()
        
        self._llm_response_worker = create_worker(
            LLMResponseWorker, sleep_time_in_ms=1
        )
        # Conversation history manager used to fetch conversation IDs and history
        self._conversation_history_manager = ConversationHistoryManager()
        self.loading = True
        self.conversation_id: int = None
        self.conversation = None
        self._llm_history_tab_index = None
        self._llm_history_widget = None
        self.ui.chat_history_widget.setVisible(False)
        self.ui.tabWidget.tabBar().hide()
        self._model_context_tokens = self._resolve_model_context_length()
        if hasattr(self.ui, "token_count"):
            self._set_token_count_label(0, self._model_context_tokens)
        
        # Initialize token tracking labels
        self._update_token_tracking_labels()

    def _apply_default_splitter_settings(self):
        if hasattr(self, "ui") and self.ui is not None:
            QApplication.processEvents()
            default_chat_splitter_config = {
                "chat_prompt_splitter": {
                    "index_to_maximize": 0,
                    "min_other_size": 50,
                }
            }
            self.load_splitter_settings(
                orientations={"chat_prompt_splitter": Qt.Orientation.Vertical},
                default_maximize_config=default_chat_splitter_config,
            )
        else:
            self.logger.warning(
                "ChatPromptWidget: UI not available when attempting to apply default splitter settings."
            )

    @Slot()
    def on_clear_conversation_button_clicked(self):
        """Create a new conversation and clear the display."""
        # Reset token counters for new conversation
        self._tokens_sent_last = 0
        self._tokens_received_last = 0
        self._tokens_sent_total = 0
        self._tokens_received_total = 0
        self._current_response_tokens = 0
        self._update_token_tracking_labels()
        
        # Create a new conversation in the database
        new_conversation = Conversation.create()
        if new_conversation:
            self.logger.info(
                f"Created new conversation with ID: {new_conversation.id}"
            )
            # Make it the current conversation
            Conversation.make_current(new_conversation.id)
            # Update GUI state
            self.conversation_id = new_conversation.id
            self.conversation = new_conversation
            self._set_api_conversation_id(new_conversation.id)
            # Clear the display
            if hasattr(self.ui, "conversation"):
                self.ui.conversation.clear_conversation()
            # Tell the backend to use this new conversation
            self.api.llm.clear_history(conversation_id=new_conversation.id)
        else:
            self.logger.error("Failed to create new conversation")
            # Fallback to old behavior
            self.api.llm.clear_history()

    @Slot(bool)
    def on_history_button_toggled(self, checked: bool):
        self.ui.settings_button.blockSignals(True)
        self.ui.settings_button.setChecked(False)
        self.ui.settings_button.blockSignals(False)
        self.ui.tabWidget.setCurrentIndex(2 if checked else 0)

    @Slot(bool)
    def on_settings_button_toggled(self, checked: bool):
        self.ui.history_button.blockSignals(True)
        self.ui.history_button.setChecked(False)
        self.ui.history_button.blockSignals(False)
        self.ui.tabWidget.setCurrentIndex(1 if checked else 0)

    @Slot()
    def on_send_button_clicked(self):
        self.do_generate()

    def _find_parent_tab_widget(self):
        """Find the parent QTabWidget containing this widget."""
        parent = self.parent()
        while parent is not None:
            from PySide6.QtWidgets import QTabWidget

            if isinstance(parent, QTabWidget):
                return parent
            parent = parent.parent()
        return None

    @property
    def action(self) -> LLMActionType:
        # Action is always AUTO (APPLICATION_COMMAND) - handles everything automatically
        return LLMActionType.APPLICATION_COMMAND

    def on_model_status_changed_signal(self, data):
        if data["model"] == ModelType.LLM:
            self.chat_loaded = data["status"] is ModelStatus.LOADED

        if not self.chat_loaded:
            self.disable_send_button()
        else:
            self.enable_send_button()

    def on_hear_signal(self, data: Dict):
        transcription = data["transcription"]
        self.prompt = transcription
        self.do_generate()

    def enable_generate(self):
        self.generating = False
        if self.held_message is not None:
            self.do_generate(prompt_override=self.held_message)
            self.held_message = None
        self.enable_send_button()

    @Slot()
    def on_stop_button_clicked(self):
        self.api.llm.interrupt()
        self.stop_progress_bar()
        self.generating = False
        self.enable_send_button()

    def do_generate(self, prompt_override=None):
        prompt = (
            self.prompt
            if (prompt_override is None or prompt_override == "")
            else prompt_override
        )
        if prompt is None or prompt == "":
            self.logger.warning("Prompt is empty")
            return

        model_load_balancer = getattr(self.api, "model_load_balancer", None)
        art_model_loaded = (
            model_load_balancer
            and ModelType.SD in model_load_balancer.get_loaded_models()
        )
        llm_loaded = (
            model_load_balancer
            and ModelType.LLM in model_load_balancer.get_loaded_models()
        )
        if art_model_loaded and not llm_loaded:
            model_load_balancer.switch_to_non_art_mode()

        if self.generating:
            if self.held_message is None:
                self.held_message = prompt
                self.disable_send_button()
                self.on_stop_button_clicked()
            return
        self.generating = True

        conversation_id = self._ensure_conversation_context()
        if conversation_id is None:
            self.logger.error(
                "Aborting chat request - unable to determine conversation ID"
            )
            self.generating = False
            return

        self.clear_prompt()
        self.start_progress_bar()
        
        # Parse slash command if present
        self.logger.info(f"do_generate called with prompt: {prompt[:100] if prompt else 'None'}...")
        slash_command, actual_prompt, action_override = self._parse_slash_command(prompt)
        self.logger.info(f"Slash command parse result: command={slash_command}, action_override={action_override}")
        
        # Determine action type - use override from slash command if present
        action = action_override if action_override else self.action
        self.logger.info(f"Final action: {action}")
        
        # Get configuration from slash command
        force_tool = None
        if slash_command and slash_command in SLASH_COMMANDS:
            cmd_config = SLASH_COMMANDS[slash_command]
            force_tool = cmd_config.get("tool")
            self.logger.info(f"Slash command /{slash_command} -> action={action}, force_tool={force_tool}")
        
        # Use actual_prompt (with slash command stripped) for token counting
        sent_tokens = self._estimate_token_count(actual_prompt)
        self._tokens_sent_last = sent_tokens
        self._tokens_sent_total += sent_tokens
        self._tokens_received_last = 0
        self._current_response_tokens = 0
        self._update_token_tracking_labels()
        
        # Create LLMRequest optimized for the action type
        llm_request = LLMRequest.for_action(action)
        
        # Set force_tool if slash command specifies one
        if force_tool:
            llm_request.force_tool = force_tool
            self.logger.info(f"Set force_tool={force_tool} on llm_request")
        
        self.logger.info(f"Sending request - action={action}, force_tool={llm_request.force_tool}, tool_categories={llm_request.tool_categories}")
        
        self.api.llm.send_request(
            prompt=actual_prompt,
            llm_request=llm_request,
            action=action,
            do_tts_reply=False,
            conversation_id=conversation_id,
        )

    def showEvent(self, event):
        super().showEvent(event)
        if not self._default_splitter_settings_applied and self.isVisible():
            self._apply_default_splitter_settings()
            self._default_splitter_settings_applied = True

        self.promptKeyPressEvent = self.ui.prompt.keyPressEvent

        self.ui.prompt.keyPressEvent = self.handle_key_press

        if not self.chat_loaded:
            self.disable_send_button()

        # Load conversation on first show
        if self.loading and hasattr(self.ui, "conversation"):
            self.logger.info(
                "First showEvent - loading most recent conversation"
            )
            self.load_conversation()
            self.loading = False

    def llm_action_changed(self, val: str):
        # Deprecated - action is now always AUTO
        pass

    def thinking_toggled(self, checked: bool) -> None:
        """Handle thinking checkbox toggle.
        
        When enabled, Qwen3 models will use <think>...</think> reasoning
        before responding. This produces more thoughtful responses but
        takes longer to generate.
        """
        self.update_llm_generator_settings(enable_thinking=checked)

    def prompt_text_changed(self) -> None:
        """Handle changes to the prompt text and highlight slash commands if present."""
        prompt = self.ui.prompt.toPlainText()
        self.prompt = prompt.strip()
        self._update_token_count_label(self.prompt)
        self.highlight_slash_command(prompt)

    def highlight_slash_command(self, prompt: str) -> None:
        """Highlight the slash command prefix (if any) in the prompt."""
        command = None
        if prompt.startswith("/"):
            candidate = prompt[1:].split(" ")[0]
            if candidate in SLASH_COMMANDS:
                command = candidate
        highlight = command is not None
        prompt_widget = self.ui.prompt
        text = prompt_widget.toPlainText()
        prompt_widget.blockSignals(True)
        cursor = QTextCursor(prompt_widget.document())
        cursor.setPosition(0)

        if command is not None:
            length = len(command) + 1  # +1 for the leading slash
        else:
            length = len(text)

        cursor.setPosition(length, QTextCursor.MoveMode.KeepAnchor)
        fmt = cursor.charFormat()
        fmt.setFontWeight(500 if highlight else 400)
        fmt.setForeground(
            Qt.GlobalColor.black if highlight else Qt.GlobalColor.white
        )
        fmt.setBackground(
            Qt.GlobalColor.yellow if highlight else Qt.GlobalColor.transparent
        )
        cursor.setCharFormat(fmt)

        if command is not None:
            # Move the cursor to the end of the command for further typing
            cursor.setPosition(length)
            cursor.setPosition(len(text), QTextCursor.MoveMode.KeepAnchor)
            fmt = cursor.charFormat()
            fmt.setFontWeight(400)
            fmt.setForeground(Qt.GlobalColor.white)
            fmt.setBackground(Qt.GlobalColor.transparent)
            cursor.setCharFormat(fmt)

        prompt_widget.blockSignals(False)

    def _setup_slash_command_completer(self) -> None:
        """Setup slash command popup for autocomplete."""
        # Build command data with descriptions
        self._slash_commands_data = []
        for cmd, config in SLASH_COMMANDS.items():
            self._slash_commands_data.append({
                "command": f"/{cmd}",
                "description": config.get("description", ""),
            })
        
        # Create popup list widget - use ToolTip type so it doesn't steal focus
        self._slash_popup = QListWidget()
        self._slash_popup.setWindowFlags(
            Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
        )
        self._slash_popup.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self._slash_popup.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
        self._slash_popup.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAlwaysOff
        )
        self._slash_popup.setMouseTracking(True)
        self._slash_popup.setStyleSheet("""
            QListWidget {
                background-color: #1e1e1e;
                color: #cccccc;
                border: 1px solid #454545;
                outline: none;
                font-family: 'Segoe UI', sans-serif;
                font-size: 13px;
            }
            QListWidget::item {
                padding: 6px 12px;
                border: none;
            }
            QListWidget::item:selected {
                background-color: #094771;
                color: white;
            }
            QListWidget::item:hover {
                background-color: #2a2d2e;
            }
        """)
        
        # Connect click signal only (not activated - we handle Enter ourselves)
        self._slash_popup.itemClicked.connect(self._on_slash_item_clicked)
        
        # Connect text change to check for slash
        self.ui.prompt.textChanged.connect(self._check_slash_command_trigger)

    def _populate_slash_popup(self, filter_text: str = "") -> None:
        """Populate the popup with matching commands."""
        self._slash_popup.clear()
        
        filter_lower = filter_text.lower()
        for cmd_data in self._slash_commands_data:
            cmd = cmd_data["command"]
            desc = cmd_data["description"]
            
            # Filter by partial match
            if filter_lower and not cmd.lower().startswith(filter_lower):
                continue
            
            # Create item with command and description
            item = QListWidgetItem()
            item.setData(Qt.ItemDataRole.UserRole, cmd)
            
            # Format: /command                    description
            display_text = f"{cmd:<20} {desc}"
            item.setText(display_text)
            
            # Use monospace for command part
            font = QFont("Consolas", 11)
            item.setFont(font)
            
            self._slash_popup.addItem(item)
        
        # Select first item
        if self._slash_popup.count() > 0:
            self._slash_popup.setCurrentRow(0)

    def _show_slash_popup(self) -> None:
        """Show the slash command popup below the cursor."""
        if self._slash_popup.count() == 0:
            self._slash_popup.hide()
            return
        
        # Calculate position - above the prompt widget
        prompt_rect = self.ui.prompt.geometry()
        global_pos = self.ui.prompt.mapToGlobal(QPoint(0, 0))
        
        # Size the popup
        item_height = 28
        visible_items = min(self._slash_popup.count(), 12)
        popup_height = visible_items * item_height + 4
        popup_width = 450
        
        # Position above the prompt
        popup_x = global_pos.x()
        popup_y = global_pos.y() - popup_height - 5
        
        self._slash_popup.setGeometry(popup_x, popup_y, popup_width, popup_height)
        self._slash_popup.show()
        self._slash_popup.raise_()

    def _check_slash_command_trigger(self) -> None:
        """Check if we should show the slash command popup."""
        if not hasattr(self, '_slash_popup'):
            return
            
        text = self.ui.prompt.toPlainText()
        
        if text.startswith("/"):
            # Get the partial command (everything after / until space or end)
            parts = text.split(" ", 1)
            partial_cmd = parts[0] if parts else "/"
            
            # Only show popup if still typing the command (no space yet)
            if len(parts) == 1:
                self._populate_slash_popup(partial_cmd)
                self._show_slash_popup()
            else:
                self._slash_popup.hide()
        else:
            self._slash_popup.hide()

    def _on_slash_item_clicked(self, item: QListWidgetItem) -> None:
        """Handle when a slash command is selected from the popup."""
        cmd = item.data(Qt.ItemDataRole.UserRole)
        
        # Get current text after the partial command
        current_text = self.ui.prompt.toPlainText()
        space_idx = current_text.find(" ")
        if space_idx > 0:
            rest = current_text[space_idx:]
        else:
            rest = " "
        
        # Set the new text
        self.ui.prompt.blockSignals(True)
        self.ui.prompt.setPlainText(cmd + rest)
        
        # Move cursor to end
        cursor = self.ui.prompt.textCursor()
        cursor.movePosition(QTextCursor.MoveOperation.End)
        self.ui.prompt.setTextCursor(cursor)
        self.ui.prompt.blockSignals(False)
        
        # Hide popup and update highlighting
        self._slash_popup.hide()
        self.highlight_slash_command(cmd + rest)
        
        # Focus back to prompt
        self.ui.prompt.setFocus()

    def _handle_slash_popup_navigation(self, key: int) -> bool:
        """Handle keyboard navigation in slash popup. Returns True if handled.
        
        Only intercepts navigation keys (Up/Down/Tab/Escape).
        All other keys pass through to allow normal typing.
        """
        if not hasattr(self, '_slash_popup') or not self._slash_popup.isVisible():
            return False
        
        if key == Qt.Key.Key_Up:
            current = self._slash_popup.currentRow()
            if current > 0:
                self._slash_popup.setCurrentRow(current - 1)
            return True
        elif key == Qt.Key.Key_Down:
            current = self._slash_popup.currentRow()
            if current < self._slash_popup.count() - 1:
                self._slash_popup.setCurrentRow(current + 1)
            return True
        elif key == Qt.Key.Key_Tab:
            # Tab selects the current item
            item = self._slash_popup.currentItem()
            if item:
                self._on_slash_item_clicked(item)
            return True
        elif key == Qt.Key.Key_Escape:
            self._slash_popup.hide()
            return True
        
        # Let all other keys pass through (including Enter - user might want to submit)
        # The popup will auto-hide if text no longer matches
        return False

    def _parse_slash_command(self, prompt: str) -> Tuple[Optional[str], str, Optional[LLMActionType]]:
        """Parse a slash command from the prompt.
        
        Args:
            prompt: The full prompt text
            
        Returns:
            Tuple of (command_name, remaining_prompt, action_type_override)
            - command_name: The slash command (e.g., "deepsearch") or None
            - remaining_prompt: The prompt with the command stripped
            - action_type_override: Optional LLMActionType to use instead of AUTO
        """
        if not prompt.startswith("/"):
            return None, prompt, None
        
        parts = prompt[1:].split(" ", 1)
        command = parts[0].lower()
        
        if command not in SLASH_COMMANDS:
            # Unknown command, treat as regular prompt
            return None, prompt, None
        
        # Get remaining prompt (everything after the command)
        remaining = parts[1].strip() if len(parts) > 1 else ""
        
        # Get action type override if specified
        cmd_config = SLASH_COMMANDS[command]
        action_override = None
        if "action" in cmd_config:
            action_name = cmd_config["action"]
            try:
                action_override = LLMActionType[action_name]
            except KeyError:
                self.logger.warning(f"Unknown action type in slash command: {action_name}")
        
        return command, remaining, action_override

    def _update_token_count_label(self, prompt: str) -> None:
        """Refresh the token count label with the latest approximation."""
        if not hasattr(self.ui, "token_count"):
            return
        token_count = self._estimate_token_count(prompt)
        self._set_token_count_label(token_count, self._model_context_tokens)

    def _estimate_token_count(self, prompt: str) -> int:
        """Estimate the number of tokens the current prompt will consume."""
        if not prompt:
            return 0
        try:
            return count_tokens_approximately(prompt)
        except Exception as exc:  # pragma: no cover - defensive fallback
            self.logger.debug("Token estimation failed: %s", exc)
            return (len(prompt) + 3) // 4

    def _set_token_count_label(
        self, token_count: int, context_limit: Optional[int]
    ) -> None:
        """Apply a consistent, human-friendly label value."""
        if not hasattr(self.ui, "token_count"):
            return
        if context_limit and context_limit > 0:
            remaining = max(context_limit - token_count, 0)
            self.ui.token_count.setText(
                f"~{token_count:,} tokens ({remaining:,} remain)"
            )
        else:
            self.ui.token_count.setText(f"~{token_count:,} tokens")

    def _update_token_tracking_labels(self) -> None:
        """Update the sent/received token tracking labels."""
        if hasattr(self.ui, "last_message_tokens"):
            self.ui.last_message_tokens.setText(
                f"Last: ↑{self._tokens_sent_last:,} ↓{self._tokens_received_last:,}"
            )
        if hasattr(self.ui, "total_tokens"):
            self.ui.total_tokens.setText(
                f"Total: ↑{self._tokens_sent_total:,} ↓{self._tokens_received_total:,}"
            )

    def _resolve_model_context_length(self) -> Optional[int]:
        settings = getattr(self, "llm_generator_settings", None)
        if not settings:
            return None

        model_service = getattr(settings, "model_service", None)
        model_version = getattr(settings, "model_version", "") or ""
        model_path = getattr(settings, "model_path", "") or ""

        if model_service == ModelService.LOCAL.value:
            context = self._lookup_local_model_context(model_version)
            if context is not None:
                return context
            return self._lookup_local_model_context(model_path)

        return None

    def _lookup_local_model_context(self, source: str) -> Optional[int]:
        if not source:
            return None
        normalized_source = str(source).strip().lower()
        if not normalized_source:
            return None

        for model_info in LLMProviderConfig.LOCAL_MODELS.values():
            name = (model_info.get("name") or "").strip().lower()
            repo_id = (model_info.get("repo_id") or "").strip().lower()

            if name and (
                normalized_source == name
                or name in normalized_source
                or normalized_source in name
            ):
                return model_info.get("context_length")
            if repo_id and (
                normalized_source == repo_id
                or repo_id in normalized_source
                or normalized_source in repo_id
            ):
                return model_info.get("context_length")

        return None

    def _model_supports_thinking(self) -> bool:
        """Check if the current model supports thinking mode.
        
        Only Qwen3 models support the enable_thinking parameter with
        <think>...</think> reasoning blocks.
        
        Returns:
            True if the model supports thinking mode, False otherwise.
        """
        settings = getattr(self, "llm_generator_settings", None)
        if not settings:
            return False

        model_service = getattr(settings, "model_service", None)
        model_version = getattr(settings, "model_version", "") or ""
        model_path = getattr(settings, "model_path", "") or ""

        if model_service == ModelService.LOCAL.value:
            # Check model_version first (e.g., "qwen3-8b")
            if self._lookup_model_supports_thinking(model_version):
                return True
            # Fall back to model_path (e.g., "Qwen/Qwen3-8B" or local path)
            return self._lookup_model_supports_thinking(model_path)

        return False

    def _lookup_model_supports_thinking(self, source: str) -> bool:
        """Look up whether a model source supports thinking mode.
        
        Args:
            source: Model identifier (name, repo_id, or path)
            
        Returns:
            True if the model supports thinking, False otherwise.
        """
        if not source:
            return False
        normalized_source = str(source).strip().lower()
        if not normalized_source:
            return False

        # Check LLMProviderConfig.LOCAL_MODELS
        for model_info in LLMProviderConfig.LOCAL_MODELS.values():
            name = (model_info.get("name") or "").strip().lower()
            repo_id = (model_info.get("repo_id") or "").strip().lower()

            if name and (
                normalized_source == name
                or name in normalized_source
                or normalized_source in name
            ):
                return model_info.get("supports_thinking", False)
            if repo_id and (
                normalized_source == repo_id
                or repo_id in normalized_source
                or normalized_source in repo_id
            ):
                return model_info.get("supports_thinking", False)

        # Fallback: Check for "qwen3" in the source name (covers custom paths)
        if "qwen3" in normalized_source:
            return True

        return False

    def _get_model_capabilities(self) -> Dict[str, bool]:
        """Get the capabilities of the current model.
        
        Returns a dict with capability flags:
        - function_calling: Can use tools/functions
        - supports_thinking: Has thinking mode (<think>...</think>)
        - rag_capable: Optimized for RAG workflows
        - vision_capable: Can process images
        - code_capable: Good at code generation
        
        Returns:
            Dict with capability flags, defaults to conservative values.
        """
        default_caps = {
            "function_calling": False,
            "supports_thinking": False,
            "rag_capable": True,  # Most models can do basic RAG
            "vision_capable": False,
            "code_capable": False,
        }
        
        settings = getattr(self, "llm_generator_settings", None)
        if not settings:
            return default_caps

        model_service = getattr(settings, "model_service", None)
        model_version = getattr(settings, "model_version", "") or ""
        model_path = getattr(settings, "model_path", "") or ""

        if model_service == ModelService.LOCAL.value:
            # Check model_version first, then model_path
            caps = self._lookup_model_capabilities(model_version)
            if caps is not None:
                return caps
            caps = self._lookup_model_capabilities(model_path)
            if caps is not None:
                return caps

        return default_caps

    def _lookup_model_capabilities(self, source: str) -> Optional[Dict[str, bool]]:
        """Look up model capabilities from LLMProviderConfig.
        
        Args:
            source: Model identifier (name, repo_id, or path)
            
        Returns:
            Dict with capability flags or None if not found.
        """
        if not source:
            return None
        normalized_source = str(source).strip().lower()
        if not normalized_source:
            return None

        for model_info in LLMProviderConfig.LOCAL_MODELS.values():
            name = (model_info.get("name") or "").strip().lower()
            repo_id = (model_info.get("repo_id") or "").strip().lower()

            matched = False
            if name and (
                normalized_source == name
                or name in normalized_source
                or normalized_source in name
            ):
                matched = True
            elif repo_id and (
                normalized_source == repo_id
                or repo_id in normalized_source
                or normalized_source in repo_id
            ):
                matched = True

            if matched:
                return {
                    "function_calling": model_info.get("function_calling", False),
                    "supports_thinking": model_info.get("supports_thinking", False),
                    "rag_capable": model_info.get("rag_capable", True),
                    "vision_capable": model_info.get("vision_capable", False),
                    "code_capable": model_info.get("code_capable", False),
                }

        return None

    def _update_action_dropdown(self) -> None:
        """Deprecated - action is always AUTO now."""
        pass

    def _update_thinking_checkbox_visibility(self) -> None:
        """Update the visibility of the thinking checkbox based on model capability."""
        if not hasattr(self.ui, "thinking_checkbox"):
            return
        
        supports_thinking = self._model_supports_thinking()
        self.ui.thinking_checkbox.setVisible(supports_thinking)

    def _refresh_model_context_tokens(self) -> None:
        self._model_context_tokens = self._resolve_model_context_length()

    def on_llm_model_changed(self, data: Dict):
        self._refresh_model_context_tokens()
        self._update_thinking_checkbox_visibility()
        self._update_action_dropdown()
        prompt_text = (
            self.ui.prompt.toPlainText().strip()
            if hasattr(self.ui, "prompt")
            else self.prompt
        )
        self._update_token_count_label(prompt_text)

    def on_application_settings_changed(self, data: Dict):
        if (
            not isinstance(data, dict)
            or data.get("setting_name") != "llm_generator_settings"
        ):
            return

        column = data.get("column_name")
        if column not in {"model_version", "model_service", "model_path"}:
            return

        self._refresh_model_context_tokens()
        self._update_thinking_checkbox_visibility()
        self._update_action_dropdown()
        prompt_text = (
            self.ui.prompt.toPlainText().strip()
            if hasattr(self.ui, "prompt")
            else self.prompt
        )
        self._update_token_count_label(prompt_text)

    def clear_prompt(self):
        self.ui.prompt.setPlainText("")

    def start_progress_bar(self):
        self.ui.progressBar.setRange(0, 0)
        self.ui.progressBar.setValue(0)

    def stop_progress_bar(self):
        self.ui.progressBar.setRange(0, 1)
        self.ui.progressBar.setValue(1)
        self.ui.progressBar.reset()

    def disable_send_button(self):
        pass

    def enable_send_button(self):
        self.ui.send_button.setEnabled(True)
        self._disabled = False

    def handle_key_press(self, event):
        # Handle slash popup navigation first
        if self._handle_slash_popup_navigation(event.key()):
            event.accept()
            return
            
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            if (
                not self._disabled
                and event.modifiers() != Qt.KeyboardModifier.ShiftModifier
            ):
                self.do_generate()
                event.accept()
                return
        if (
            self.originalKeyPressEvent is not None
            and self.originalKeyPressEvent != self.handle_key_press
        ):
            self.originalKeyPressEvent(event)

    def hide_action_menu(self):
        self.action_menu_displayed = False
        self.ui.action_menu.hide()

    def display_action_menu(self):
        self.action_menu_displayed = True
        self.ui.action_menu.show()

    def insert_newline(self):
        self.ui.prompt.insertPlainText("\n")

    def message_type_text_changed(self, val):
        self.update_llm_generator_settings(message_type=val)

    def on_add_bot_message_to_conversation(self, data: Dict):
        llm_response = data.get("response", None)
        if llm_response is None:
            return
            
        # Track received tokens from streaming response
        message = getattr(llm_response, "message", "") or ""
        if message:
            chunk_tokens = self._estimate_token_count(message)
            self._current_response_tokens += chunk_tokens
        
        # Update labels when response is complete
        if getattr(llm_response, "is_end_of_message", False):
            self._tokens_received_last = self._current_response_tokens
            self._tokens_received_total += self._current_response_tokens
            self._update_token_tracking_labels()
        
        if getattr(llm_response, "is_first_message", False):
            self.stop_progress_bar()
            self.enable_generate()

    def on_llm_text_generate_request_signal(self, data: Dict):
        """Handle LLM text generate request signal - user message is being sent."""
        self.logger.debug(f"LLM_TEXT_GENERATE_REQUEST_SIGNAL received: {data}")
        # The ConversationWidget should handle displaying the message
        # This handler is just for logging/debugging

    def load_conversation(self, conversation_id: int = None):
        """Load a conversation and synchronize with ConversationWidget."""
        if conversation_id is None:
            # Try to load the current conversation first, fall back to most recent
            current_conversation = (
                self._conversation_history_manager.get_current_conversation()
            )
            if current_conversation:
                conversation_id = current_conversation.id
            else:
                conversation_id = (
                    self._conversation_history_manager.get_most_recent_conversation_id()
                )
        if conversation_id is None:
            if hasattr(self.ui, "conversation"):
                self.ui.conversation.clear_conversation()
            self.conversation = None
            self.conversation_id = None
            self._set_api_conversation_id(None)
            return
        self.conversation_id = conversation_id
        self._set_api_conversation_id(conversation_id)

        conversation = Conversation.objects.filter_by_first(id=conversation_id)
        self.conversation = conversation
        if hasattr(self.api, "llm") and hasattr(self.api.llm, "clear_history"):
            self.api.llm.clear_history(conversation_id=conversation_id)
        messages = (
            self._conversation_history_manager.load_conversation_history(
                conversation_id=conversation_id, max_messages=50
            )
        )
        self.logger.info(
            f"Loaded {len(messages)} messages from conversation {conversation_id}"
        )
        for idx, msg in enumerate(messages):
            self.logger.info(
                f"  Message {idx}: is_bot={msg.get('is_bot')}, content_preview={msg.get('content', '')[:50]}"
            )

        if hasattr(self.ui, "conversation"):
            self.ui.conversation.conversation = conversation
            self.ui.conversation.set_conversation_widgets(messages)

    def on_queue_load_conversation(self, data):
        conversation_id = data.get("index")
        self.load_conversation(conversation_id=conversation_id)

    def on_delete_conversation(self, data):
        deleted_id = data.get("conversation_id")
        if self.conversation_id == deleted_id:
            if hasattr(self.ui, "conversation"):
                self.ui.conversation.clear_conversation()
            self.conversation = None
            self.conversation_id = None
            self._set_api_conversation_id(None)

    def _clear_conversation(self, skip_update: bool = False):
        pass

    def _set_conversation_widgets(self, messages, skip_scroll: bool = False):
        pass

    def _clear_conversation_widgets(self, skip_update: bool = False):
        pass

    def add_message_to_conversation(self, *args, **kwargs):
        pass

    def on_mood_summary_update_started(self, *args, **kwargs):
        pass

    def _handle_mood_summary_update_started(self, *args, **kwargs):
        pass

    def register_web_channel(self, channel):
        pass

    def _ensure_conversation_context(self) -> Optional[int]:
        """Ensure we have a valid conversation ID before sending a request."""
        if self.conversation_id is not None:
            return self.conversation_id

        conversation = (
            self._conversation_history_manager.get_current_conversation()
        )
        if conversation is not None:
            self.conversation = conversation
            self.conversation_id = conversation.id
            self._set_api_conversation_id(conversation.id)
            return self.conversation_id

        conversation = Conversation.create()
        if conversation is None:
            self.logger.error("Failed to create a new conversation")
            return None

        Conversation.make_current(conversation.id)
        self.conversation = conversation
        self.conversation_id = conversation.id
        self._set_api_conversation_id(conversation.id)
        if hasattr(self.ui, "conversation"):
            self.ui.conversation.clear_conversation()
        return conversation.id

    def _set_api_conversation_id(self, conversation_id: Optional[int]) -> None:
        api = getattr(self, "api", None)
        if api is not None:
            setattr(api, "current_conversation_id", conversation_id)

    def _populate_model_dropdown(self) -> None:
        """Populate the model dropdown with available models for current provider."""
        if not hasattr(self.ui, "model_dropdown"):
            return
        
        self.ui.model_dropdown.blockSignals(True)
        self.ui.model_dropdown.clear()
        
        # Get current provider from dropdown or settings
        provider = ModelService.LOCAL.value
        if hasattr(self.ui, "provider_dropdown") and self.ui.provider_dropdown.count() > 0:
            provider = self.ui.provider_dropdown.currentData() or ModelService.LOCAL.value
        
        # Make dropdown editable for custom model entry
        self.ui.model_dropdown.setEditable(True)
        self.ui.model_dropdown.setInsertPolicy(self.ui.model_dropdown.InsertPolicy.NoInsert)
        
        # Get models based on provider
        if provider == ModelService.LOCAL.value:
            # HuggingFace/Local models - show from LOCAL_MODELS config
            models = LLMProviderConfig.get_models_for_provider("local")
            for model_id in models:
                if model_id == "custom":
                    continue
                model_info = LLMProviderConfig.get_model_info("local", model_id)
                if model_info:
                    display_name = model_info.get("name", model_id)
                    self.ui.model_dropdown.addItem(display_name, model_id)
            
            # Add custom option at end
            self.ui.model_dropdown.addItem("-- Custom Path --", "custom")
            
        elif provider == ModelService.OLLAMA.value:
            # Ollama models
            models = LLMProviderConfig.get_models_for_provider("ollama")
            for model_id in models:
                if model_id == "custom":
                    continue
                self.ui.model_dropdown.addItem(model_id, model_id)
            
            # Add custom option
            self.ui.model_dropdown.addItem("-- Custom Model --", "custom")
            
        elif provider == ModelService.OPENROUTER.value:
            # OpenRouter models
            models = LLMProviderConfig.get_models_for_provider("openrouter")
            for model_id in models:
                if model_id == "custom":
                    continue
                self.ui.model_dropdown.addItem(model_id, model_id)
            
            # Add custom option
            self.ui.model_dropdown.addItem("-- Custom Model --", "custom")
        
        # Try to restore current selection
        self._restore_model_selection(provider)
        
        self.ui.model_dropdown.blockSignals(False)
    
    def _restore_model_selection(self, provider: str) -> None:
        """Restore model selection based on saved settings."""
        # First try to match by saved model_id (most reliable)
        saved_model_id = getattr(self.llm_generator_settings, "model_id", None) or ""
        if saved_model_id:
            for i in range(self.ui.model_dropdown.count()):
                item_model_id = self.ui.model_dropdown.itemData(i)
                if item_model_id == saved_model_id:
                    self.ui.model_dropdown.setCurrentIndex(i)
                    if provider == ModelService.LOCAL.value:
                        self._update_model_tooltip(saved_model_id)
                        
                        # Verify model_path is correct - rebuild if corrupted
                        current_path = getattr(self.llm_generator_settings, "model_path", "") or ""
                        # Check for corrupted paths (TTS/SD/art model paths)
                        invalid_patterns = ["/tts/", "/openvoice", "/art/models/", "/txt2img", "/inpaint"]
                        is_corrupted = not current_path or any(pattern in current_path for pattern in invalid_patterns)
                        if is_corrupted:
                            # Path is missing or corrupted - rebuild from model_id
                            model_info = LLMProviderConfig.get_model_info("local", saved_model_id)
                            if model_info:
                                model_name = model_info.get("name", saved_model_id)
                                base_path = os.path.expanduser(
                                    getattr(self.path_settings, "base_path", "~/.local/share/airunner")
                                )
                                correct_path = os.path.join(base_path, f"text/models/llm/causallm/{model_name}")
                                self.logger.info(
                                    f"Rebuilding corrupted model_path from model_id '{saved_model_id}': {correct_path}"
                                )
                                self.update_llm_generator_settings(
                                    model_path=correct_path,
                                    model_version=model_name,
                                )
                    return
        
        # Fallback: try to match by model path or version
        if provider == ModelService.LOCAL.value:
            # For HuggingFace, match by model path
            current_path = getattr(self.llm_generator_settings, "model_path", "") or ""
            if current_path:
                # Check for corrupted path (TTS/SD/art model paths should not be in LLM settings)
                invalid_patterns = ["/tts/", "/openvoice", "/art/models/", "/txt2img", "/inpaint"]
                is_corrupted = any(pattern in current_path for pattern in invalid_patterns)
                if is_corrupted:
                    self.logger.warning(
                        f"Detected corrupted LLM model_path: {current_path}. "
                        "Attempting to recover from model_id..."
                    )
                    # Try to recover from model_id first
                    if saved_model_id and saved_model_id != "custom":
                        model_info = LLMProviderConfig.get_model_info("local", saved_model_id)
                        if model_info:
                            model_name = model_info.get("name", saved_model_id)
                            base_path = os.path.expanduser(
                                getattr(self.path_settings, "base_path", "~/.local/share/airunner")
                            )
                            correct_path = os.path.join(base_path, f"text/models/llm/causallm/{model_name}")
                            self.logger.info(f"Recovered corrupted model_path to: {correct_path}")
                            self.update_llm_generator_settings(
                                model_path=correct_path,
                                model_version=model_name,
                            )
                            return
                    # No recovery possible - clear the corrupted path
                    self.update_llm_generator_settings(model_path="")
                    return
                    
                for i in range(self.ui.model_dropdown.count()):
                    model_id = self.ui.model_dropdown.itemData(i)
                    if model_id == "custom":
                        continue
                    model_info = LLMProviderConfig.get_model_info("local", model_id)
                    if model_info:
                        model_name = model_info.get("name", "")
                        if model_name and model_name in current_path:
                            self.ui.model_dropdown.setCurrentIndex(i)
                            self._update_model_tooltip(model_id)
                            return
        else:
            # For Ollama/OpenRouter, match by model_version
            current_model = getattr(self.llm_generator_settings, "model_version", "") or ""
            if current_model:
                for i in range(self.ui.model_dropdown.count()):
                    model_id = self.ui.model_dropdown.itemData(i)
                    if model_id == current_model or self.ui.model_dropdown.itemText(i) == current_model:
                        self.ui.model_dropdown.setCurrentIndex(i)
                        return
                # If not found in list, it might be custom - set as text
                self.ui.model_dropdown.setEditText(current_model)

    def _select_and_save_default_model(self, provider: str) -> None:
        """Select and save the first available model as default.
        
        Called when corrupted settings are detected to auto-recover.
        
        Args:
            provider: Current provider (local, ollama, openrouter)
        """
        if provider == ModelService.LOCAL.value:
            # Find the first non-custom model
            for i in range(self.ui.model_dropdown.count()):
                model_id = self.ui.model_dropdown.itemData(i)
                if model_id and model_id != "custom":
                    # Select in dropdown
                    self.ui.model_dropdown.setCurrentIndex(i)
                    self._update_model_tooltip(model_id)
                    
                    # Build the correct path and save to settings
                    model_info = LLMProviderConfig.get_model_info("local", model_id)
                    if model_info:
                        model_name = model_info.get("name", model_id)
                        base_path = os.path.expanduser(
                            getattr(self.path_settings, "base_path", "~/.local/share/airunner")
                        )
                        model_path = os.path.join(base_path, f"text/models/llm/causallm/{model_name}")
                        
                        # Save to database
                        self.update_llm_generator_settings(
                            model_path=model_path,
                            model_version=model_name,
                            model_id=model_id,
                        )
                        self.logger.info(f"Auto-selected default model: {model_id} ({model_path})")
                    return
        else:
            # For remote providers, select first non-custom model
            for i in range(self.ui.model_dropdown.count()):
                model_id = self.ui.model_dropdown.itemData(i)
                if model_id and model_id != "custom":
                    self.ui.model_dropdown.setCurrentIndex(i)
                    self.update_llm_generator_settings(
                        model_version=model_id,
                        model_path="",
                        model_id=model_id,
                    )
                    self.logger.info(f"Auto-selected default model: {model_id}")
                    return

    def _update_model_tooltip(self, model_id: str) -> None:
        """Update the model dropdown tooltip with model metadata."""
        if not hasattr(self.ui, "model_dropdown"):
            return
        
        # Get current provider
        provider = ModelService.LOCAL.value
        if hasattr(self.ui, "provider_dropdown") and self.ui.provider_dropdown.count() > 0:
            provider = self.ui.provider_dropdown.currentData() or ModelService.LOCAL.value
        
        if model_id == "custom":
            if provider == ModelService.LOCAL.value:
                self.ui.model_dropdown.setToolTip("Enter a custom model path or HuggingFace repo ID")
            elif provider == ModelService.OLLAMA.value:
                self.ui.model_dropdown.setToolTip("Enter any Ollama model name (e.g., llama3.2:latest)")
            else:
                self.ui.model_dropdown.setToolTip("Enter any OpenRouter model ID (e.g., anthropic/claude-3-sonnet)")
            return
        
        if provider == ModelService.LOCAL.value:
            # HuggingFace models - show full metadata
            model_info = LLMProviderConfig.get_model_info("local", model_id)
            if not model_info:
                self.ui.model_dropdown.setToolTip("Select LLM model")
                return
            
            vram_gb = model_info.get("vram_4bit_gb", "?")
            context_length = model_info.get("context_length", 0)
            context_k = f"{context_length // 1000}K" if context_length >= 1000 else str(context_length)
            
            tool_mode = model_info.get("tool_calling_mode", "none")
            tool_str = tool_mode.upper() if tool_mode != "none" else "None"
            
            gguf_file = model_info.get("gguf_filename", "")
            gguf_str = gguf_file if gguf_file else "Not available"
            
            description = model_info.get("description", "")
            
            tooltip = f"~{vram_gb}GB VRAM | {context_k} context | Tools: {tool_str}\n"
            tooltip += f"GGUF: {gguf_str}"
            if description:
                tooltip += f"\n{description}"
            
            self.ui.model_dropdown.setToolTip(tooltip)
            
        elif provider == ModelService.OLLAMA.value:
            # Ollama - simpler tooltip
            self.ui.model_dropdown.setToolTip(f"Ollama model: {model_id}\nRequires Ollama running locally")
            
        elif provider == ModelService.OPENROUTER.value:
            # OpenRouter - show model ID
            self.ui.model_dropdown.setToolTip(f"OpenRouter model: {model_id}\nRequires OpenRouter API key")

    def _populate_provider_dropdown(self) -> None:
        """Populate the provider dropdown with available providers."""
        if not hasattr(self.ui, "provider_dropdown"):
            return
        
        self.ui.provider_dropdown.blockSignals(True)
        self.ui.provider_dropdown.clear()
        
        # Provider options with display name -> ModelService value mapping
        providers = [
            ("HuggingFace", ModelService.LOCAL.value),
            ("Ollama", ModelService.OLLAMA.value),
            ("OpenRouter", ModelService.OPENROUTER.value),
        ]
        
        for display_name, service_value in providers:
            self.ui.provider_dropdown.addItem(display_name, service_value)
        
        # Set current selection based on saved settings
        current_service = getattr(self.llm_generator_settings, "model_service", ModelService.LOCAL.value)
        for i in range(self.ui.provider_dropdown.count()):
            if self.ui.provider_dropdown.itemData(i) == current_service:
                self.ui.provider_dropdown.setCurrentIndex(i)
                break
        
        self.ui.provider_dropdown.blockSignals(False)

    @Slot(int)
    def on_provider_changed(self, index: int) -> None:
        """Handle provider selection change from dropdown."""
        if index < 0:
            return
        
        provider = self.ui.provider_dropdown.itemData(index)
        if not provider:
            return
        
        # Update settings with new provider
        self.update_llm_generator_settings(model_service=provider)
        
        # Repopulate model dropdown for new provider
        self._populate_model_dropdown()

    @Slot(int)
    def on_model_changed(self, index: int) -> None:
        """Handle model selection change from dropdown."""
        if index < 0:
            return
        
        model_id = self.ui.model_dropdown.itemData(index)
        model_text = self.ui.model_dropdown.currentText()
        
        # Get current provider
        provider = ModelService.LOCAL.value
        if hasattr(self.ui, "provider_dropdown") and self.ui.provider_dropdown.count() > 0:
            provider = self.ui.provider_dropdown.currentData() or ModelService.LOCAL.value
        
        # Handle custom model entry
        if model_id == "custom" or not model_id:
            # User is entering custom model - use the text they typed
            custom_model = model_text if model_text and not model_text.startswith("--") else ""
            if custom_model:
                self._handle_custom_model(provider, custom_model)
            return
        
        # Update tooltip with model metadata
        self._update_model_tooltip(model_id)
        
        if provider == ModelService.LOCAL.value:
            # HuggingFace/Local model
            model_info = LLMProviderConfig.get_model_info("local", model_id)
            if not model_info:
                return
            
            model_name = model_info.get("name", model_id)
            base_path = os.path.expanduser(
                getattr(self.path_settings, "base_path", "~/.local/share/airunner")
            )
            model_path = os.path.join(base_path, f"text/models/llm/causallm/{model_name}")
            
            self.update_llm_generator_settings(
                model_path=model_path,
                model_version=model_name,
                model_id=model_id,  # Save the provider config model ID
            )
            
            # Emit signal that model changed (will trigger reload/download)
            self.emit_signal(
                SignalCode.LLM_MODEL_CHANGED,
                {"model_path": model_path, "model_name": model_name},
            )
        else:
            # Ollama or OpenRouter - just update model_version
            self.update_llm_generator_settings(
                model_version=model_id,
                model_path="",  # Not used for remote providers
                model_id=model_id,  # Save the model ID
            )
            
            # Emit signal
            self.emit_signal(
                SignalCode.LLM_MODEL_CHANGED,
                {"model_path": "", "model_name": model_id},
            )
        
        # Update thinking checkbox visibility based on new model
        self._update_thinking_checkbox_visibility()
        
        # Update context tokens
        self._refresh_model_context_tokens()

    def _handle_custom_model(self, provider: str, custom_model: str) -> None:
        """Handle custom model entry for any provider."""
        if provider == ModelService.LOCAL.value:
            # Could be a path or HuggingFace repo ID
            if "/" in custom_model and not os.path.exists(custom_model):
                # Likely a HuggingFace repo ID
                base_path = os.path.expanduser(
                    getattr(self.path_settings, "base_path", "~/.local/share/airunner")
                )
                # Extract model name from repo ID (e.g., "mistralai/Mistral-7B" -> "Mistral-7B")
                model_name = custom_model.split("/")[-1]
                model_path = os.path.join(base_path, f"text/models/llm/causallm/{model_name}")
            else:
                # Assume it's a local path
                model_path = custom_model
                model_name = os.path.basename(custom_model)
            
            self.update_llm_generator_settings(
                model_path=model_path,
                model_version=model_name,
            )
            self.emit_signal(
                SignalCode.LLM_MODEL_CHANGED,
                {"model_path": model_path, "model_name": model_name},
            )
        else:
            # Ollama or OpenRouter - just set the model name
            self.update_llm_generator_settings(
                model_version=custom_model,
                model_path="",
            )
            self.emit_signal(
                SignalCode.LLM_MODEL_CHANGED,
                {"model_path": "", "model_name": custom_model},
            )

    def _on_custom_model_entered(self) -> None:
        """Handle when user presses Enter after typing a custom model."""
        if not hasattr(self.ui, "model_dropdown"):
            return
        
        custom_text = self.ui.model_dropdown.currentText()
        if not custom_text or custom_text.startswith("--"):
            return
        
        # Check if this is already a known model
        for i in range(self.ui.model_dropdown.count()):
            if self.ui.model_dropdown.itemText(i) == custom_text:
                # It's a known model, don't treat as custom
                return
        
        # Get current provider
        provider = ModelService.LOCAL.value
        if hasattr(self.ui, "provider_dropdown") and self.ui.provider_dropdown.count() > 0:
            provider = self.ui.provider_dropdown.currentData() or ModelService.LOCAL.value
        
        # Handle as custom model
        self._handle_custom_model(provider, custom_text)
