# SPDX-License-Identifier: GPL-2.0-only
#
# llamachat - a small native chat client for a local llama.cpp router
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
"""The chat window."""

import dataclasses
import datetime
import html
import json
import re
from pathlib import Path

from PySide6.QtCore import (
    QObject, QRect, QSettings, Qt, QThread, Signal, Slot,
)
from PySide6.QtGui import (
    QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument,
)
from PySide6.QtWidgets import (
    QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
    QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
    QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
    QPushButton, QRadioButton, QSplitter, QTextBrowser, QToolButton,
    QVBoxLayout, QWidget,
)

from . import backend, models as models_mod, prompts
from . import providers as providers_mod
from .backend import Attachment, BackendError, SearchConfig
from .config import GLOBAL_PROMPT
from .modeldialog import ModelDialog

MODE_ONESHOT = "oneshot"
MODE_CHAT = "chat"

# URL schemes for the collapsible toggles. A reply is untrusted text, so
# links it produces are stripped rather than trusted to not collide with
# these. Two schemes rather than one so the search and thinking blocks on a
# single message expand independently.
REASONING_SCHEME = "x-llamachat-reasoning:"
SEARCH_SCHEME = "x-llamachat-search:"


class ContextMeter(QWidget):
    """A bar showing how much of the model's context the next request uses.

    The number is an estimate while typing and becomes exact after a reply,
    since the streaming response reports the real prompt token count.
    """

    WARN = 0.75
    DANGER = 0.90

    def __init__(self):
        super().__init__()
        self.used = 0
        self.limit = 0
        self.exact = False
        self.setFixedWidth(190)
        self.setFixedHeight(18)

    def set_usage(self, used: int, limit: int, exact: bool) -> None:
        if (used, limit, exact) == (self.used, self.limit, self.exact):
            return
        self.used, self.limit, self.exact = used, limit, exact
        if exact:
            tip = f"{used:,} of {limit:,} context tokens used"
        else:
            # A model with a reasoning budget can spend thousands of tokens
            # thinking, which nothing can predict before the reply arrives.
            tip = (
                f"about {used:,} of {limit:,} context tokens\n"
                "Estimated. Reasoning tokens are not counted until the "
                "reply arrives."
            )
        self.setToolTip(tip)
        self.update()

    def fraction(self) -> float:
        if self.limit <= 0:
            return 0.0
        return min(self.used / self.limit, 1.0)

    def paintEvent(self, event) -> None:
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        track = self.rect().adjusted(0, 4, -1, -5)
        painter.setPen(Qt.NoPen)
        painter.setBrush(self.palette().mid())
        painter.drawRoundedRect(track, 3, 3)

        fraction = self.fraction()
        if fraction > 0:
            filled = QRect(track)
            filled.setWidth(max(2, int(track.width() * fraction)))
            painter.setBrush(QColor(self._colour()))
            painter.drawRoundedRect(filled, 3, 3)

        painter.setPen(self.palette().windowText().color())
        font = painter.font()
        font.setPointSizeF(max(7.0, font.pointSizeF() - 1.5))
        painter.setFont(font)
        painter.drawText(
            self.rect(), Qt.AlignCenter, self._label()
        )
        painter.end()

    def _colour(self) -> str:
        fraction = self.fraction()
        if fraction >= self.DANGER:
            return "#c0392b"
        if fraction >= self.WARN:
            return "#d68910"
        return "#2e86c1"

    def _label(self) -> str:
        if self.limit <= 0:
            return "context ?"
        marker = "" if self.exact else "~"
        return f"{marker}{_short(self.used)} / {_short(self.limit)}"


def _short(count: int) -> str:
    if count >= 1000:
        return f"{count / 1000:.1f}k".replace(".0k", "k")
    return str(count)


def cost_text(spent: float, projected: float, billable: bool, priced: bool) -> str:
    """The cost label beside the context meter.

    Three states, deliberately distinct: a local model shows nothing, a
    cloud model with no price entered shows '?', and a priced one shows
    what it has cost plus what the composed draft would add. Blank and '?'
    must not collapse into each other, or an unpriced cloud model reads as
    free.
    """
    if not billable:
        return ""
    if not priced:
        return "?"
    text = models_mod.format_cost(spent)
    if projected > 0:
        text += f" +{models_mod.format_cost(projected)}"
    return text


class CostLabel(QLabel):
    """A one-line money readout that hides itself when there is nothing to say."""

    def __init__(self):
        super().__init__("")
        self.setToolTip("")

    def set_cost(
        self, spent: float, projected: float, billable: bool, priced: bool
    ) -> None:
        text = cost_text(spent, projected, billable, priced)
        self.setText(text)
        self.setVisible(bool(text))
        if not billable:
            self.setToolTip("")
        elif not priced:
            self.setToolTip(
                "No prices set for this model.\n"
                "Use Model settings to enter them."
            )
        else:
            tip = f"{models_mod.format_cost(spent)} spent in this conversation"
            if projected > 0:
                tip += (
                    f"\n+{models_mod.format_cost(projected)} to send what is "
                    "composed now"
                )
            tip += "\nApproximate: based on the prices you entered."
            self.setToolTip(tip)


class StreamWorker(QObject):
    """Runs one streaming request off the GUI thread."""

    chunk = Signal(str)
    reasoning = Signal(str)
    usage = Signal(int, int)
    search_start = Signal(str)
    search_done = Signal(str)
    finished = Signal()
    failed = Signal(str)

    def __init__(
        self,
        client,
        model: str,
        messages: list[dict],
        search_cfg: SearchConfig | None = None,
    ):
        super().__init__()
        self.client = client
        self.model = model
        self.messages = messages
        self.search_cfg = search_cfg
        self._stop = False

    def stop(self) -> None:
        self._stop = True

    @Slot()
    def run(self) -> None:
        # Resolve the client and wire name on the worker thread, not the
        # GUI thread: client_for() resolves the API key, and a pass: key
        # runs `pass show` which blocks on a pinentry for as long as the user
        # takes to find and touch their hardware token. Resolving in
        # _start_stream before moveToThread would freeze the whole window.
        try:
            client = self.client.client_for(self.model)
            wire = self.client.wire_name(self.model)
        except providers_mod.KeyResolutionError as exc:
            self.failed.emit(str(exc))
            return
        try:
            stream = client.stream_chat(
                wire, self.messages, self.search_cfg
            )
            for kind, piece in stream:
                if self._stop:
                    break
                if kind == "reasoning":
                    self.reasoning.emit(piece)
                elif kind == "search_start":
                    self.search_start.emit(piece)
                elif kind == "search_done":
                    self.search_done.emit(piece)
                elif kind == "usage":
                    stats = json.loads(piece)
                    self.usage.emit(
                        int(stats.get("prompt_tokens") or 0),
                        int(stats.get("total_tokens") or 0),
                    )
                else:
                    self.chunk.emit(piece)
        except BackendError as exc:
            self.failed.emit(str(exc))
            return
        except Exception as exc:  # noqa: BLE001 - surface anything as UI text
            self.failed.emit(f"Unexpected error: {exc}")
            return
        self.finished.emit()


class TitleWorker(QObject):
    """Asks the model to name a conversation, off the GUI thread.

    Silent by design: a title is a nicety, so any failure leaves the
    fallback title alone rather than showing the user an error.
    """

    ready = Signal(int, str)
    finished = Signal()

    def __init__(self, client, model: str, session_id: int, messages: list[dict]):
        super().__init__()
        self.client = client
        self.model = model
        self.session_id = session_id
        self.messages = messages

    @Slot()
    def run(self) -> None:
        # Resolve on the worker thread for the same reason as StreamWorker:
        # a pass: key blocks on a pinentry. Titling is never the user's own
        # turn, so a key error skips silently rather than showing a banner.
        try:
            client = self.client.client_for(self.model)
            wire = self.client.wire_name(self.model)
            raw = client.complete(wire, self.messages)
            title = backend.clean_title(raw)
        except providers_mod.KeyResolutionError:
            return  # silent skip, titling is never the user's turn
        except Exception:  # noqa: BLE001 - a missing title is not an error
            title = ""
        if title:
            self.ready.emit(self.session_id, title)
        self.finished.emit()


class PromptDialog(QDialog):
    """Browse, edit and add system prompts.

    Editing writes straight to the file in the prompts directory, so the
    same prompts can be edited in a text editor or kept in version control.
    """

    def __init__(self, store: prompts.PromptStore, parent=None):
        super().__init__(parent)
        self.store = store
        self.current: str | None = None

        self.setWindowTitle("System prompts")
        self.resize(720, 480)

        layout = QVBoxLayout(self)
        body = QHBoxLayout()

        self.list = QListWidget()
        self.list.setMaximumWidth(200)
        self.list.currentTextChanged.connect(self._select)
        body.addWidget(self.list)

        right = QVBoxLayout()
        self.path_label = QLabel()
        self.path_label.setWordWrap(True)
        self.path_label.setStyleSheet("color: palette(mid)")
        right.addWidget(self.path_label)

        self.editor = QPlainTextEdit()
        self.editor.setPlaceholderText("The system prompt sent to the model.")
        right.addWidget(self.editor, 1)
        body.addLayout(right, 1)
        layout.addLayout(body, 1)

        buttons = QHBoxLayout()
        add = QPushButton("New…")
        add.clicked.connect(self._add)
        buttons.addWidget(add)

        self.delete_button = QPushButton("Delete")
        self.delete_button.clicked.connect(self._delete)
        buttons.addWidget(self.delete_button)

        buttons.addStretch()
        box = QDialogButtonBox(
            QDialogButtonBox.Save | QDialogButtonBox.Close
        )
        box.button(QDialogButtonBox.Save).clicked.connect(self._save)
        box.rejected.connect(self.reject)
        buttons.addWidget(box)
        layout.addLayout(buttons)

        self._reload()

    def _reload(self, select: str | None = None) -> None:
        self.store.ensure_default()
        names = self.store.names()
        self.list.blockSignals(True)
        self.list.clear()
        self.list.addItems(names)
        self.list.blockSignals(False)

        target = select or (names[0] if names else None)
        if target:
            matches = self.list.findItems(target, Qt.MatchExactly)
            if matches:
                self.list.setCurrentItem(matches[0])
            self._select(target)

    def _select(self, name: str) -> None:
        if not name:
            return
        self._save_pending()
        prompt = self.store.load(name)
        self.current = name
        self.editor.setPlainText(prompt.text if prompt else "")
        self.path_label.setText(str(self.store.path_for(name)))
        # The global prompt is the fallback for everything, so it stays.
        self.delete_button.setEnabled(name != GLOBAL_PROMPT)

    def _save_pending(self) -> None:
        """Persist edits before switching away from a prompt."""
        if self.current is None:
            return
        prompt = self.store.load(self.current)
        if prompt is not None and prompt.text != self.editor.toPlainText():
            self.store.save(self.current, self.editor.toPlainText())

    def _save(self) -> None:
        if self.current is not None:
            self.store.save(self.current, self.editor.toPlainText())
        self.accept()

    def _add(self) -> None:
        name, ok = QInputDialog.getText(self, "New prompt", "Name:")
        if not ok or not name.strip():
            return
        cleaned = prompts.safe_name(name)
        if not cleaned:
            QMessageBox.warning(self, "New prompt", "That name cannot be used.")
            return
        if self.store.load(cleaned) is not None:
            QMessageBox.warning(
                self, "New prompt", f"{cleaned} already exists."
            )
            return
        self._save_pending()
        self.store.save(cleaned, "")
        self.current = None  # avoid writing the old text into the new file
        self._reload(select=cleaned)
        self.editor.setFocus()

    def _delete(self) -> None:
        if self.current is None or self.current == GLOBAL_PROMPT:
            return
        answer = QMessageBox.question(
            self,
            "Delete prompt",
            f"Delete the prompt {self.current}?\n\n"
            f"{self.store.path_for(self.current)}",
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.No,
        )
        if answer != QMessageBox.Yes:
            return
        self.store.delete(self.current)
        self.current = None
        self._reload()


class ChatWindow(QMainWindow):
    """Model picker, mode toggle, transcript, input, history panel."""

    def __init__(self, cfg, history, client, presets, store):
        super().__init__()
        self.cfg = cfg
        self.history = history
        self.client = client
        self.presets = presets
        self.store = store
        self.prompts = prompts.PromptStore(cfg.prompts_dir)
        self.prompts.ensure_default()

        # Which system prompt this conversation uses: a name from the store,
        # the NONE sentinel, or CUSTOM with text held in prompt_custom.
        self.prompt_name = cfg.default_prompt
        self.prompt_custom = ""
        self.exact_tokens = 0
        self.sidebar_width = 240

        self.session_id: int | None = None
        self.mode = MODE_ONESHOT
        self.read_only = False
        self.attachments: list[Attachment] = []
        self.thread: QThread | None = None
        self.worker: StreamWorker | None = None
        # Titling runs on its own short-lived threads. Each (thread, worker)
        # pair is held here for as long as the request is in flight, since
        # neither survives on the Qt side alone.
        self.title_threads: list[tuple] = []
        self.assistant_message_id: int | None = None
        self.assistant_buffer = ""
        self.reasoning_buffer = ""
        self.searches: list[dict] = []
        self.turn_prompt_tokens: int | None = None
        self.turn_completion_tokens: int | None = None
        self._conversation_cost = 0.0
        # Every rendered bubble, so a reasoning toggle can redraw the
        # transcript without refetching anything.
        self.bubbles: list[dict] = []
        self.expanded: set[int] = set()
        self.expanded_searches: set[int] = set()

        self.setWindowTitle("llamachat")
        self.resize(1000, 700)
        self.setAcceptDrops(True)
        self._build_ui()
        self._restore_layout()
        self.refresh_models()
        self.refresh_prompts()
        self.refresh_history()
        self.update_meter()

    # -- construction -----------------------------------------------------

    def _build_ui(self) -> None:
        central = QWidget()
        outer = QVBoxLayout(central)

        # Top bar: model, mode, search.
        top = QHBoxLayout()
        self.sidebar_button = QPushButton("☰")
        self.sidebar_button.setCheckable(True)
        self.sidebar_button.setChecked(True)
        self.sidebar_button.setFixedWidth(32)
        self.sidebar_button.setToolTip("Show or hide the history panel (Ctrl+\\)")
        self.sidebar_button.toggled.connect(self.set_sidebar_visible)
        top.addWidget(self.sidebar_button)

        self.model_box = QComboBox()
        self.model_box.setMinimumWidth(220)
        self.model_box.currentTextChanged.connect(self._on_model_changed)
        top.addWidget(QLabel("Model:"))
        top.addWidget(self.model_box)

        self.model_settings_button = QToolButton()
        self.model_settings_button.setText("⚙")
        self.model_settings_button.setToolTip(
            "Context size, vision and prices for the selected model"
        )
        self.model_settings_button.clicked.connect(self.edit_model_settings)
        top.addWidget(self.model_settings_button)

        self.reload_button = QPushButton("⟳")
        self.reload_button.setToolTip("Reload model list from the router")
        self.reload_button.setFixedWidth(32)
        self.reload_button.clicked.connect(self.refresh_models)
        top.addWidget(self.reload_button)

        top.addSpacing(16)
        self.oneshot_radio = QRadioButton("One-shot")
        self.chat_radio = QRadioButton("Chat")
        self.oneshot_radio.setChecked(True)
        group = QButtonGroup(self)
        group.addButton(self.oneshot_radio)
        group.addButton(self.chat_radio)
        self.oneshot_radio.toggled.connect(self._on_mode_changed)
        top.addWidget(self.oneshot_radio)
        top.addWidget(self.chat_radio)

        top.addSpacing(16)
        top.addWidget(QLabel("Prompt:"))
        self.prompt_box = QComboBox()
        self.prompt_box.setMinimumWidth(150)
        self.prompt_box.currentIndexChanged.connect(self._on_prompt_changed)
        top.addWidget(self.prompt_box)

        self.prompt_button = QPushButton("…")
        self.prompt_button.setToolTip("View and edit system prompts")
        self.prompt_button.setFixedWidth(32)
        self.prompt_button.clicked.connect(self.edit_prompts)
        top.addWidget(self.prompt_button)

        top.addStretch()
        self.meter = ContextMeter()
        top.addWidget(self.meter)

        self.cost = CostLabel()
        top.addWidget(self.cost)

        self.search_box = QLineEdit()
        self.search_box.setPlaceholderText("Search history… (Ctrl+F)")
        self.search_box.setClearButtonEnabled(True)
        self.search_box.setMaximumWidth(280)
        self.search_box.textChanged.connect(self._on_search)
        top.addWidget(self.search_box)
        outer.addLayout(top)

        # Body: history list beside the transcript and input.
        self.splitter = QSplitter(Qt.Horizontal)
        splitter = self.splitter

        self.history_list = QListWidget()
        self.history_list.setMinimumWidth(200)
        self.history_list.itemActivated.connect(self._on_history_activated)
        self.history_list.itemClicked.connect(self._on_history_activated)
        self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
        self.history_list.customContextMenuRequested.connect(
            self._on_history_menu
        )
        splitter.addWidget(self.history_list)

        right = QWidget()
        right_layout = QVBoxLayout(right)
        right_layout.setContentsMargins(0, 0, 0, 0)

        self.transcript = QTextBrowser()
        # Links are handled here, not followed: reasoning:N toggles a
        # thinking block, anything else opens in the browser.
        self.transcript.setOpenLinks(False)
        self.transcript.anchorClicked.connect(self._on_anchor_clicked)
        right_layout.addWidget(self.transcript, 1)

        self.attach_label = QLabel()
        self.attach_label.setWordWrap(True)
        self.attach_label.hide()
        right_layout.addWidget(self.attach_label)

        self.status_label = QLabel()
        self.status_label.hide()
        right_layout.addWidget(self.status_label)

        self.input = QPlainTextEdit()
        self.input.setPlaceholderText(
            "Type a message. Ctrl+Enter sends, Esc hides the window."
        )
        self.input.setMaximumHeight(140)
        self.input.textChanged.connect(self.update_meter)
        right_layout.addWidget(self.input)

        buttons = QHBoxLayout()
        self.new_button = QPushButton("New")
        self.new_button.setToolTip("Start a fresh conversation (Ctrl+N)")
        self.new_button.clicked.connect(self.new_session)
        buttons.addWidget(self.new_button)

        self.attach_button = QPushButton("Attach…")
        self.attach_button.clicked.connect(self._on_attach_clicked)
        buttons.addWidget(self.attach_button)

        self.clear_attach_button = QPushButton("Clear files")
        self.clear_attach_button.clicked.connect(self.clear_attachments)
        self.clear_attach_button.hide()
        buttons.addWidget(self.clear_attach_button)

        buttons.addStretch()
        self.stop_button = QPushButton("Stop")
        self.stop_button.clicked.connect(self.stop_stream)
        self.stop_button.hide()
        buttons.addWidget(self.stop_button)

        self.send_button = QPushButton("Send")
        self.send_button.setDefault(True)
        self.send_button.clicked.connect(self.send)
        buttons.addWidget(self.send_button)
        right_layout.addLayout(buttons)

        splitter.addWidget(right)
        splitter.setStretchFactor(0, 0)
        splitter.setStretchFactor(1, 1)
        splitter.setSizes([240, 760])
        outer.addWidget(splitter, 1)

        self.setCentralWidget(central)

        send_action = QAction(self)
        send_action.setShortcut(QKeySequence("Ctrl+Return"))
        send_action.triggered.connect(self.send)
        self.addAction(send_action)

        send_enter = QAction(self)
        send_enter.setShortcut(QKeySequence("Ctrl+Enter"))
        send_enter.triggered.connect(self.send)
        self.addAction(send_enter)

        sidebar_action = QAction(self)
        sidebar_action.setShortcut(QKeySequence("Ctrl+\\"))
        sidebar_action.triggered.connect(self.toggle_sidebar)
        self.addAction(sidebar_action)

        new_action = QAction(self)
        new_action.setShortcut(QKeySequence("Ctrl+N"))
        new_action.triggered.connect(self.new_session)
        self.addAction(new_action)

        search_action = QAction(self)
        search_action.setShortcut(QKeySequence("Ctrl+F"))
        search_action.triggered.connect(self.focus_search)
        self.addAction(search_action)

    # -- sidebar ----------------------------------------------------------

    def _settings(self) -> QSettings:
        """Where window layout is remembered.

        Kept beside the config rather than left to QSettings' default so it
        is obvious where it lives, and so tests can point it elsewhere.
        """
        return QSettings(str(self.cfg.state_path), QSettings.IniFormat)

    def _restore_layout(self) -> None:
        """Bring back the sidebar width and visibility from last time."""
        settings = self._settings()
        self.sidebar_width = int(settings.value("sidebar/width", 240))
        visible = settings.value("sidebar/visible", True)
        # QSettings round-trips booleans as strings on some backends.
        if isinstance(visible, str):
            visible = visible.lower() not in ("false", "0")
        # setChecked only emits when the value changes, so apply directly
        # rather than relying on the signal to do it. capture=False keeps
        # the width just read from disk: the splitter has not been laid out
        # yet, so asking it now would overwrite that with a default.
        self.sidebar_button.blockSignals(True)
        self.sidebar_button.setChecked(bool(visible))
        self.sidebar_button.blockSignals(False)
        self.set_sidebar_visible(bool(visible), capture=False)

    def _save_layout(self) -> None:
        # Hiding already captured the width, so only refresh it while the
        # panel is up and the splitter still has something to report.
        if self.sidebar_visible():
            self._capture_width()

        settings = self._settings()
        settings.setValue("sidebar/width", self.sidebar_width)
        settings.setValue("sidebar/visible", self.sidebar_visible())
        settings.sync()

    def toggle_sidebar(self) -> None:
        self.sidebar_button.setChecked(not self.sidebar_button.isChecked())

    def focus_search(self) -> None:
        """Jump to the search box in the top bar.

        Results land in the history panel, so a hidden panel is revealed
        rather than leaving the search with nowhere to show its hits.
        """
        if not self.sidebar_visible():
            self.sidebar_button.setChecked(True)
        self.search_box.setFocus()
        self.search_box.selectAll()

    def sidebar_visible(self) -> bool:
        """Whether the panel is shown.

        isVisible() is False for every child of a window that has not been
        mapped yet, so the button's own state is the authority before the
        window first appears.
        """
        return self.sidebar_button.isChecked()

    def _capture_width(self) -> None:
        """Remember the panel's width while the splitter still reports it.

        Read unconditionally rather than gated on visibility: by the time a
        toggle reaches here the button has already flipped, so asking
        whether the sidebar is shown would skip the capture.
        """
        sizes = self.splitter.sizes()
        # A zero first pane means it is already collapsed, so there is no
        # meaningful width to keep.
        if sizes and sizes[0] > 0:
            self.sidebar_width = sizes[0]

    def set_sidebar_visible(self, visible: bool, capture: bool = True) -> None:
        """Show or hide the history panel, keeping its width across toggles.

        `capture` is False when restoring saved state, where the width comes
        from disk and the splitter has nothing meaningful to report yet.
        """
        if not visible and capture:
            self._capture_width()

        if self.sidebar_button.isChecked() != visible:
            self.sidebar_button.blockSignals(True)
            self.sidebar_button.setChecked(visible)
            self.sidebar_button.blockSignals(False)
        self.history_list.setVisible(visible)
        self.sidebar_button.setToolTip(
            f"{'Hide' if visible else 'Show'} the history panel (Ctrl+\\)"
        )
        if visible:
            total = sum(self.splitter.sizes()) or self.width()
            width = min(self.sidebar_width, max(total - 200, 0))
            self.splitter.setSizes([width, total - width])

    # -- model handling ---------------------------------------------------

    def refresh_models(self) -> None:
        """Repopulate the picker from every provider, keeping the selection."""
        previous = self.model_box.currentText()
        available, listing_problems = self.client.models()
        if not available:
            self.show_status(
                "; ".join(listing_problems) or "No models available", error=True
            )
            return

        self.model_box.blockSignals(True)
        self.model_box.clear()
        for name in available:
            info = self.model_info(name)
            label = f"{name} 👁" if info.vision else name
            self.model_box.addItem(label, name)
        self.model_box.blockSignals(False)

        target = previous or self.cfg.default_model
        if target:
            index = self.model_box.findData(_strip_marker(target))
            if index < 0:
                index = self.model_box.findText(target)
            if index >= 0:
                self.model_box.setCurrentIndex(index)
        # Skipped providers from config parsing and listing failures from
        # unreachable providers are both worth saying, even when others worked.
        all_problems = list(self.cfg.provider_warnings) + list(listing_problems)
        if all_problems:
            self.show_status("; ".join(all_problems), error=True)
        else:
            self.hide_status()
        self.update_cost()

    def current_model(self) -> str:
        return self.model_box.currentData() or self.model_box.currentText()

    def model_info(self, model_id: str):
        """Metadata for one model: models.ini, then provider, then presets.

        Local models get their context and vision from presets.ini, which
        the first two layers can still override if the user entered values.
        Uses dataclasses.replace so the ModelInfo that resolve() returns is
        never mutated: when a model has no configured provider, resolve()
        returns the stored instance directly, and mutating it would corrupt
        the store's in-memory representation.
        """
        info = models_mod.resolve(model_id, self.cfg.providers, self.store)
        preset = self.presets.get(model_id)
        if preset is not None:
            updates = {}
            if info.ctx_size is None:
                updates["ctx_size"] = preset.ctx_size
            if info.vision is None:
                updates["vision"] = preset.vision
            if updates:
                info = dataclasses.replace(info, **updates)
        return info

    def current_info(self):
        return self.model_info(self.current_model())

    def current_preset(self):
        return self.presets.get(self.current_model())

    def char_budget(self) -> int:
        ctx = self.current_info().ctx_size or 4096
        return int(ctx * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction)

    def vision_models(self) -> list[str]:
        names = []
        for i in range(self.model_box.count()):
            name = self.model_box.itemData(i)
            if self.model_info(name).vision:
                names.append(name)
        return names

    def edit_model_settings(self, model_id: str | None = None) -> bool:
        """Open the dialog for one model. True when values were saved."""
        model_id = model_id or self.current_model()
        if not model_id:
            return False
        current = models_mod.resolve(model_id, self.cfg.providers, self.store)
        dialog = ModelDialog(model_id, current, self)
        if dialog.exec() != QDialog.Accepted:
            self.store.mark_skipped(model_id)
            return False
        self.store.save(model_id, dialog.info())
        self.update_meter()
        return True

    def _maybe_offer_model_settings(self, model_id: str) -> None:
        """Ask once, on first selection of an unconfigured cloud model.

        Local models are exempt: presets.ini already answers context and
        vision for them and they cost nothing. Cancelling records that the
        offer was made, so a model tried once never asks again.
        """
        if not model_id or not models_mod.is_billable(model_id, self.cfg.providers):
            return
        if self.store.was_offered(model_id):
            return
        self.edit_model_settings(model_id)

    def _on_model_changed(self, _text: str) -> None:
        self._maybe_offer_model_settings(self.current_model())
        if self.session_id is not None and not self.read_only:
            self.history.touch_session(self.session_id, self.current_model())
        self.update_meter()

    # -- system prompts ---------------------------------------------------

    def refresh_prompts(self) -> None:
        """Repopulate the prompt picker, keeping the current selection."""
        previous = self.prompt_name
        self.prompt_box.blockSignals(True)
        self.prompt_box.clear()
        for name in self.prompts.names():
            label = "default (global)" if name == GLOBAL_PROMPT else name
            self.prompt_box.addItem(label, name)
        self.prompt_box.addItem("custom for this chat…", prompts.CUSTOM)
        self.prompt_box.addItem("no system prompt", prompts.NONE)
        self.prompt_box.blockSignals(False)
        self.select_prompt(previous)

    def select_prompt(self, name: str) -> None:
        """Set the picker without treating it as a user choice."""
        self.prompt_name = name
        index = self.prompt_box.findData(name or GLOBAL_PROMPT)
        if index < 0:
            index = self.prompt_box.findData(GLOBAL_PROMPT)
        self.prompt_box.blockSignals(True)
        if index >= 0:
            self.prompt_box.setCurrentIndex(index)
        self.prompt_box.blockSignals(False)
        self.update_meter()

    def _on_prompt_changed(self, _index: int) -> None:
        choice = self.prompt_box.currentData()
        if choice == prompts.CUSTOM:
            text, ok = QInputDialog.getMultiLineText(
                self,
                "Custom system prompt",
                "Used for this conversation only:",
                self.prompt_custom or self.system_prompt_text(),
            )
            if not ok:
                self.select_prompt(self.prompt_name)  # revert
                return
            self.prompt_custom = text
        self.prompt_name = choice
        if self.session_id is not None and not self.read_only:
            self.history.set_prompt(
                self.session_id, self.prompt_name, self.prompt_custom
            )
        self.update_meter()

    def system_prompt_text(self) -> str:
        """The prompt text this conversation will actually send."""
        return self.prompts.resolve(self.prompt_name, self.prompt_custom)

    def edit_prompts(self) -> None:
        dialog = PromptDialog(self.prompts, self)
        dialog.exec()
        self.refresh_prompts()

    # -- mode handling ----------------------------------------------------

    def _on_mode_changed(self, _checked: bool) -> None:
        new_mode = MODE_ONESHOT if self.oneshot_radio.isChecked() else MODE_CHAT
        if new_mode == self.mode:
            return
        self.mode = new_mode
        self.new_session()

    def new_session(self) -> None:
        """Drop the current conversation and start clean."""
        self.session_id = None
        self.read_only = False
        self.assistant_message_id = None
        self.assistant_buffer = ""
        self.reasoning_buffer = ""
        self.searches = []
        self.exact_tokens = 0
        self.bubbles.clear()
        self.expanded.clear()
        self.expanded_searches.clear()
        self.clear_attachments()
        self.transcript.clear()
        self.input.clear()
        self.input.setReadOnly(False)
        self.send_button.setEnabled(True)
        self.history_list.clearSelection()
        self.hide_status()
        self.select_prompt(self.cfg.default_prompt)
        self._conversation_cost = 0.0
        self.update_meter()
        # Starting a conversation implies wanting to type in it, and this
        # also takes focus back out of the search box.
        self.input.setFocus()
        self.update_cost()

    # -- attachments ------------------------------------------------------

    def _on_attach_clicked(self) -> None:
        paths, _ = QFileDialog.getOpenFileNames(self, "Attach files")
        if paths:
            self.add_attachments([Path(p) for p in paths])

    def add_attachments(self, paths: list[Path]) -> None:
        """Load files, refusing images unless the model can see them."""
        wants_vision = any(backend.classify(p) == "image" for p in paths)
        if wants_vision and not self._ensure_vision_model():
            return

        for path in paths:
            try:
                att = backend.load_attachment(path, self.char_budget())
            except (BackendError, OSError) as exc:
                self.show_status(f"{path.name}: {exc}", error=True)
                continue
            self.attachments.append(att)
            if att.truncated:
                self.show_status(
                    f"{path.name} was truncated to fit the model context "
                    f"({self.char_budget()} characters).",
                    error=True,
                )
        self.update_attach_label()

    def _ensure_vision_model(self) -> bool:
        """Offer to switch to a vision preset. True when one is active."""
        info = self.current_info()
        if info.vision:
            return True
        # Unknown is not the same as "no": a cloud model we know nothing
        # about may well accept images, and the API will say so if it does
        # not. Only a model known to lack vision gets stopped here.
        if info.vision is None:
            return True

        candidates = self.vision_models()
        if not candidates:
            QMessageBox.warning(
                self,
                "No vision model",
                "No model on the router has an mmproj file configured, so "
                "images cannot be sent.",
            )
            return False

        target = candidates[0]
        answer = QMessageBox.question(
            self,
            "Switch model?",
            f"Images need a vision-capable model.\n\n"
            f"Switch to {target}?\n\n"
            "The router unloads the current model to do this, so the next "
            "reply will take several seconds to start.",
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.Yes,
        )
        if answer != QMessageBox.Yes:
            return False

        index = self.model_box.findData(target)
        if index >= 0:
            self.model_box.setCurrentIndex(index)
        return True

    def clear_attachments(self) -> None:
        self.attachments.clear()
        self.update_attach_label()

    def update_attach_label(self) -> None:
        if not self.attachments:
            self.attach_label.hide()
            self.clear_attach_button.hide()
            self.update_meter()
            return
        names = []
        for att in self.attachments:
            mark = "🖼" if att.kind == "image" else "📄"
            suffix = " (truncated)" if att.truncated else ""
            names.append(f"{mark} {att.path.name}{suffix}")
        self.attach_label.setText("Attached: " + ", ".join(names))
        self.attach_label.show()
        self.clear_attach_button.show()
        self.update_meter()

    # -- drag and drop ----------------------------------------------------

    def dragEnterEvent(self, event) -> None:
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dragMoveEvent(self, event) -> None:
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event) -> None:
        paths = [
            Path(url.toLocalFile())
            for url in event.mimeData().urls()
            if url.isLocalFile()
        ]
        if paths:
            self.add_attachments(paths)
            event.acceptProposedAction()

    # -- sending ----------------------------------------------------------

    def send(self) -> None:
        if self.thread is not None:
            return
        if self.read_only:
            self.show_status(
                "One-shot entries are read-only. Press New to start again.",
                error=True,
            )
            return

        text = self.input.toPlainText().strip()
        if not text and not self.attachments:
            return
        model = self.current_model()
        if not model:
            self.show_status("No model selected.", error=True)
            return

        if self.session_id is None:
            title = backend.placeholder_title(
                text or self.attachments[0].path.name
            )
            self.session_id = self.history.create_session(
                self.mode,
                model,
                title,
                prompt_name=self.prompt_name,
                prompt_custom=self.prompt_custom,
            )

        content = backend.build_user_content(text, self.attachments)
        stored = content if isinstance(content, str) else content[0]["text"]
        message_id = self.history.add_message(self.session_id, "user", stored)
        for att in self.attachments:
            self.history.add_attachment(
                message_id,
                path=str(att.path),
                kind=att.kind,
                mime=att.mime,
                size=att.size,
                sha256=att.sha256,
                thumb=att.thumb,
                truncated=att.truncated,
            )

        self.append_bubble("user", stored, self.attachments)
        self.input.clear()

        if self.mode == MODE_CHAT:
            messages = self._chat_context(content)
        else:
            messages = self._system_messages()
            messages.append({"role": "user", "content": content})

        self.clear_attachments()
        self.refresh_history()
        self._start_stream(model, messages)

    def _chat_context(self, latest_content) -> list[dict]:
        """Prior turns plus the new message, for multi-turn mode."""
        messages = self._system_messages()
        rows = self.history.messages(self.session_id)
        for row in rows[:-1]:  # the latest user row is replaced below
            messages.append({"role": row["role"], "content": row["content"]})
        messages.append({"role": "user", "content": latest_content})
        return messages

    def _system_messages(self) -> list[dict]:
        parts = [self.system_prompt_text()]
        if self.cfg.search_enabled:
            parts.append(_date_note())
        text = "\n\n".join(p for p in parts if p)
        return [{"role": "system", "content": text}] if text else []

    def update_meter(self) -> None:
        """Refresh the context bar from whatever is currently composed.

        Exact once a reply has reported its prompt tokens, an estimate
        before that, and the draft in the input box is included so the cost
        of a long paste is visible before sending.
        """
        if not hasattr(self, "meter"):
            return  # still building the window
        self.update_cost()
        info = self.current_info()
        limit = info.ctx_size or 0

        draft = self.input.toPlainText()
        pending = backend.build_user_content(draft, self.attachments)

        if self.exact_tokens and self.mode == MODE_CHAT:
            # Add only what has been composed since the last exchange.
            extra = backend.estimate_tokens(
                [{"role": "user", "content": pending}], self.cfg.chars_per_token
            ) if (draft or self.attachments) else 0
            self.meter.set_usage(
                self.exact_tokens + extra, limit, exact=not extra
            )
            return

        messages = self._system_messages()
        if self.session_id is not None and self.mode == MODE_CHAT:
            for row in self.history.messages(self.session_id):
                messages.append(
                    {"role": row["role"], "content": row["content"]}
                )
        if draft or self.attachments:
            messages.append({"role": "user", "content": pending})

        used = backend.estimate_tokens(messages, self.cfg.chars_per_token)
        self.meter.set_usage(used, limit, exact=False)

    def update_cost(self) -> None:
        """Refresh the money readout from stored counts plus the draft."""
        if not hasattr(self, "cost"):
            return  # still building the window
        model_id = self.current_model()
        billable = models_mod.is_billable(model_id, self.cfg.providers)
        priced = models_mod.is_priced(model_id, self.cfg.providers, self.store)
        if not billable or not priced:
            self.cost.set_cost(0.0, 0.0, billable, priced)
            return

        # Reopening a conversation resends its whole history, so the
        # projection has to price everything that would go out, not just
        # what was typed. That is what makes an expensive turn visible
        # before it is paid rather than after.
        pending = 0
        if self.session_id is not None or self.input.toPlainText():
            pending = backend.estimate_tokens(
                self._chat_context(
                    backend.build_user_content(
                        self.input.toPlainText(), self.attachments
                    )
                ),
                self.cfg.chars_per_token,
            )
        projected = models_mod.projected_cost(
            pending, model_id, self.cfg.providers, self.store
        )
        self.cost.set_cost(self._conversation_cost, projected, billable, priced)

    def _search_config(self) -> SearchConfig:
        return SearchConfig(
            enabled=self.cfg.search_enabled,
            url=self.cfg.search_url,
            results=self.cfg.search_results,
            snippet_chars=self.cfg.search_snippet_chars,
            timeout=self.cfg.search_timeout,
            max_searches=self.cfg.max_searches,
        )

    def _start_stream(self, model: str, messages: list[dict]) -> None:
        self.assistant_buffer = ""
        self.reasoning_buffer = ""
        self.searches = []
        self.assistant_message_id = self.history.add_message(
            self.session_id, "assistant", ""
        )
        self.append_bubble("assistant", "")
        self.show_status(f"Waiting for {model}… (a model switch takes a while)")
        self.send_button.setEnabled(False)
        self.stop_button.show()

        self.thread = QThread(self)
        self.worker = StreamWorker(
            self.client, model, messages, self._search_config()
        )
        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.run)
        self.worker.chunk.connect(self._on_chunk)
        self.worker.reasoning.connect(self._on_reasoning)
        self.worker.search_start.connect(self._on_search_start)
        self.worker.search_done.connect(self._on_search_done)
        self.worker.usage.connect(self._on_usage)
        self.worker.finished.connect(self._on_stream_finished)
        self.worker.failed.connect(self._on_stream_failed)
        self.thread.start()

    @Slot(str)
    def _on_chunk(self, piece: str) -> None:
        if not self.assistant_buffer:
            self.hide_status()
        self.assistant_buffer += piece
        self._update_last_bubble(text=self.assistant_buffer)

    @Slot(int, int)
    def _on_usage(self, prompt_tokens: int, total_tokens: int) -> None:
        """Replace the estimate with the counts the server reported."""
        info = self.current_info()
        limit = info.ctx_size or 0
        # What the next turn starts from is everything sent plus the reply.
        self.exact_tokens = total_tokens or prompt_tokens
        self.turn_prompt_tokens = prompt_tokens
        self.turn_completion_tokens = max(total_tokens - prompt_tokens, 0)
        self.meter.set_usage(self.exact_tokens, limit, exact=True)
        self.update_cost()

    @Slot(str)
    def _on_reasoning(self, piece: str) -> None:
        if not self.reasoning_buffer:
            self.hide_status()
        self.reasoning_buffer += piece
        self._update_last_bubble(reasoning=self.reasoning_buffer)

    @Slot(str)
    def _on_search_start(self, query: str) -> None:
        # A bad answer is usually a bad query, so show it while it runs.
        self.show_status(f"searching: {query}…")

    @Slot(str)
    def _on_search_done(self, payload: str) -> None:
        try:
            record = json.loads(payload)
        except ValueError:
            return
        self.searches.append(record)
        self.hide_status()
        self._update_last_bubble(searches=self.searches)

    def _searches_json(self) -> str | None:
        """Stored form of this turn's searches: NULL when there were none."""
        return json.dumps(self.searches) if self.searches else None

    @Slot()
    def _on_stream_finished(self) -> None:
        if self.assistant_message_id is not None:
            self.history.update_message(
                self.assistant_message_id,
                self.assistant_buffer,
                self.reasoning_buffer,
                self._searches_json(),
                prompt_tokens=self.turn_prompt_tokens,
                completion_tokens=self.turn_completion_tokens,
                model=self.current_model(),
            )
            rows = self.history.messages(self.session_id)
            self._conversation_cost = models_mod.conversation_cost(
                rows, self.cfg.providers, self.store
            )
        titled = self.assistant_message_id is not None and self._should_title()
        self._teardown_stream()
        if titled:
            self._start_titling()
        self.refresh_history()
        self.update_cost()

    def _should_title(self) -> bool:
        session = self.history.get_session(self.session_id)
        rows = self.history.messages(self.session_id)
        if session is None or not rows:
            return False
        return backend.needs_title(
            session["title"] or "", rows[0]["content"], self.assistant_buffer
        )

    def _start_titling(self) -> None:
        """Replace the first-words placeholder with a model-written title."""
        rows = self.history.messages(self.session_id)
        model = self.current_model()
        if not model:
            return
        # The question this reply answers, which in a one-shot window is not
        # necessarily the one the session was created from.
        question = next(
            (r["content"] for r in reversed(rows) if r["role"] == "user"), ""
        )
        messages = backend.title_messages(question, self.assistant_buffer)

        thread = QThread(self)
        worker = TitleWorker(self.client, model, self.session_id, messages)
        worker.moveToThread(thread)
        thread.started.connect(worker.run)
        worker.ready.connect(self._on_title_ready)
        worker.finished.connect(thread.quit)
        # Both halves are held until the thread ends. moveToThread does not
        # take ownership, so a worker kept alive only by a local is collected
        # as soon as this returns, taking its queued started() call with it
        # and leaving a thread that runs forever without ever calling run().
        pair = (thread, worker)
        self.title_threads.append(pair)
        thread.finished.connect(
            lambda p=pair: p in self.title_threads
            and self.title_threads.remove(p)
        )
        thread.start()

    @Slot(int, str)
    def _on_title_ready(self, session_id: int, title: str) -> None:
        self.history.set_title(session_id, title)
        self.refresh_history()

    @Slot(str)
    def _on_stream_failed(self, message: str) -> None:
        if self.assistant_message_id is not None:
            self.history.update_message(
                self.assistant_message_id,
                self.assistant_buffer,
                self.reasoning_buffer,
                self._searches_json(),
            )
        self.show_status(message, error=True)
        self._teardown_stream()

    def stop_stream(self) -> None:
        if self.worker is not None:
            self.worker.stop()

    def _teardown_stream(self) -> None:
        if self.thread is not None:
            self.thread.quit()
            self.thread.wait(3000)
        self.thread = None
        self.worker = None
        self.assistant_message_id = None
        self.turn_prompt_tokens = None
        self.turn_completion_tokens = None
        self.send_button.setEnabled(True)
        self.stop_button.hide()

    # -- transcript rendering ---------------------------------------------

    def append_bubble(
        self,
        role: str,
        text: str,
        attachments: list[Attachment] | None = None,
        saved_rows=None,
        reasoning: str = "",
        searches: list[dict] | None = None,
    ) -> None:
        self.bubbles.append(
            {
                "role": role,
                "text": text,
                "attachments": attachments,
                "saved_rows": saved_rows,
                "reasoning": reasoning,
                "searches": searches or [],
            }
        )
        self._render()

    def _update_last_bubble(
        self,
        text: str | None = None,
        reasoning: str | None = None,
        searches: list[dict] | None = None,
    ) -> None:
        """Rewrite the streaming assistant bubble."""
        if not self.bubbles:
            return
        if text is not None:
            self.bubbles[-1]["text"] = text
        if reasoning is not None:
            self.bubbles[-1]["reasoning"] = reasoning
        if searches is not None:
            self.bubbles[-1]["searches"] = list(searches)
        self._render(live=True)

    def _render(self, live: bool = False) -> None:
        """Redraw the whole transcript.

        Rewriting everything keeps the collapse state and the streaming
        update on one code path. Conversations are short enough that the
        cost does not show.
        """
        bar = self.transcript.verticalScrollBar()
        at_end = bar.value() >= bar.maximum() - 4
        previous = bar.value()

        parts = []
        for i, bubble in enumerate(self.bubbles):
            streaming = live and i == len(self.bubbles) - 1
            parts.append(
                _bubble_html(
                    bubble["role"],
                    bubble["text"],
                    bubble["attachments"],
                    bubble["saved_rows"],
                    reasoning=bubble["reasoning"],
                    index=i,
                    expanded=i in self.expanded,
                    live=streaming and not bubble["text"],
                    searches=bubble.get("searches") or [],
                    searches_expanded=i in self.expanded_searches,
                )
            )
        self.transcript.setHtml("".join(parts))

        if at_end:
            self._scroll_to_end()
        else:
            bar.setValue(previous)

    def _on_anchor_clicked(self, url) -> None:
        """Expand or collapse a thinking or search block."""
        target = url.toString()
        if target.startswith("blocked:"):
            return  # a link a reply tried to forge
        if target.startswith(REASONING_SCHEME):
            self._toggle(self.expanded, target[len(REASONING_SCHEME) :])
            return
        if target.startswith(SEARCH_SCHEME):
            self._toggle(self.expanded_searches, target[len(SEARCH_SCHEME) :])
            return
        if url.scheme() in ("http", "https", "mailto"):
            QDesktopServices.openUrl(url)

    def _toggle(self, which: set[int], raw: str) -> None:
        try:
            index = int(raw)
        except ValueError:
            return
        if index in which:
            which.discard(index)
        else:
            which.add(index)
        self._render(live=self.thread is not None)

    def _scroll_to_end(self) -> None:
        bar = self.transcript.verticalScrollBar()
        bar.setValue(bar.maximum())

    # -- history panel ----------------------------------------------------

    def refresh_history(self) -> None:
        if self.search_box.text().strip():
            return  # search results are showing; leave them alone
        self.history_list.clear()
        for row in self.history.recent_sessions():
            marker = "💬" if row["mode"] == MODE_CHAT else "⚡"
            item = QListWidgetItem(f"{marker} {row['title'] or '(untitled)'}")
            item.setData(Qt.UserRole, row["id"])
            item.setToolTip(f"{row['model'] or '?'} — {row['mode']}")
            self.history_list.addItem(item)

    def _on_search(self, text: str) -> None:
        if not text.strip():
            self.refresh_history()
            return
        self.history_list.clear()
        for row in self.history.search(text):
            item = QListWidgetItem(
                f"{row['title'] or '(untitled)'}\n  {_plain(row['snip'])}"
            )
            item.setData(Qt.UserRole, row["session_id"])
            self.history_list.addItem(item)

    def _on_history_activated(self, item: QListWidgetItem) -> None:
        session_id = item.data(Qt.UserRole)
        if session_id is not None:
            self.open_session(int(session_id))

    def _on_history_menu(self, point) -> None:
        item = self.history_list.itemAt(point)
        if item is None:
            return
        menu = QMenu(self)
        delete = menu.addAction("Delete")
        if menu.exec(self.history_list.mapToGlobal(point)) == delete:
            self.history.delete_session(int(item.data(Qt.UserRole)))
            if self.session_id == int(item.data(Qt.UserRole)):
                self.new_session()
            self.refresh_history()

    def open_session(self, session_id: int) -> None:
        """Load a past conversation. Chat mode continues, one-shot is read-only."""
        session = self.history.get_session(session_id)
        if session is None:
            return

        self.session_id = session_id
        self.mode = session["mode"]
        self.read_only = self.mode == MODE_ONESHOT
        self.exact_tokens = 0
        self.clear_attachments()

        # Restore the prompt the conversation was built with, so continuing
        # it does not silently change the model's instructions.
        self.prompt_custom = _column(session, "prompt_custom")
        self.select_prompt(_column(session, "prompt_name"))

        self.oneshot_radio.blockSignals(True)
        self.chat_radio.blockSignals(True)
        self.oneshot_radio.setChecked(self.mode == MODE_ONESHOT)
        self.chat_radio.setChecked(self.mode == MODE_CHAT)
        self.oneshot_radio.blockSignals(False)
        self.chat_radio.blockSignals(False)

        if session["model"]:
            index = self.model_box.findData(session["model"])
            if index >= 0:
                self.model_box.setCurrentIndex(index)

        self.bubbles.clear()
        self.expanded.clear()
        self.expanded_searches.clear()
        for row in self.history.messages(session_id):
            self.bubbles.append(
                {
                    "role": row["role"],
                    "text": row["content"],
                    "attachments": None,
                    "saved_rows": self.history.attachments(row["id"]),
                    "reasoning": _column(row, "reasoning"),
                    "searches": _searches(_column(row, "searches")),
                }
            )
        self._conversation_cost = models_mod.conversation_cost(
            self.history.messages(session_id), self.cfg.providers, self.store
        )
        self._render()
        self._scroll_to_end()

        self.input.setReadOnly(self.read_only)
        self.send_button.setEnabled(not self.read_only)
        self.prompt_box.setEnabled(not self.read_only)
        self.update_meter()
        if self.read_only:
            self.show_status(
                "One-shot entry, read-only. Press New to start a fresh chat."
            )
        else:
            self.hide_status()
        self.update_cost()

    # -- status line ------------------------------------------------------

    def show_status(self, text: str, error: bool = False) -> None:
        self.status_label.setText(text)
        self.status_label.setStyleSheet(
            "color: palette(bright-text); background: palette(highlight); "
            "padding: 4px; border-radius: 3px;"
            if error
            else "padding: 4px;"
        )
        self.status_label.show()

    def hide_status(self) -> None:
        self.status_label.hide()

    # -- window behaviour -------------------------------------------------

    def keyPressEvent(self, event) -> None:
        if event.key() == Qt.Key_Escape:
            # Escape backs out of the search box before it hides the window,
            # so a stray press while filtering does not dismiss everything.
            # focusWidget() rather than hasFocus(), so this still holds when
            # the window itself is not the active one.
            if self.focusWidget() is self.search_box:
                if self.search_box.text():
                    self.search_box.clear()
                else:
                    self.input.setFocus()
                return
            self.hide()
            return
        super().keyPressEvent(event)

    def hideEvent(self, event) -> None:
        """The daemon rarely exits, so persist layout whenever it hides."""
        self._save_layout()
        super().hideEvent(event)

    def closeEvent(self, event) -> None:
        """Closing hides; the daemon keeps running for the next toggle."""
        self._save_layout()
        event.ignore()
        self.hide()


# GitHub dialect for tables and strikethrough; NoHTML so markup in a reply
# is shown literally instead of being interpreted.
MARKDOWN_FLAGS = (
    QTextDocument.MarkdownDialectGitHub | QTextDocument.MarkdownNoHTML
)

# Qt renders fenced code as one <pre> per line with no background of its
# own. Giving those a tint is what makes a code block read as a block.
_PRE_STYLE = (
    "background:rgba(127,127,127,0.18);padding:1px 6px;margin:0;"
    "font-family:monospace"
)


_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})", re.MULTILINE)


def _close_fences(text: str) -> str:
    """Close an unterminated code fence and neuter a lone inline backtick.

    A reply is streamed, so a fence is unclosed on nearly every frame; a
    model can also emit an odd backtick by accident. Either way the rest of
    the reply would reflow as code, which is how a whole message turns into
    one grey block. Closing the fence here bounds the damage to the text
    that was really meant as code.
    """
    fences = _FENCE_RE.findall(text)
    if len(fences) % 2:
        # Match the opener's own length; a longer run is legal and only a
        # fence at least as long as the opener actually closes it.
        closer = fences[-1][0] * max(3, len(fences[-1]))
        nl = "" if text.endswith("\n") else "\n"
        return f"{text}{nl}{closer}"
    if not fences and text.count("`") % 2:
        # No fences at all, one dangling inline tick: drop it rather than
        # let it open a run to the end of the message.
        head, _, tail = text.rpartition("`")
        return head + tail
    return text


def _markdown_to_fragment(text: str) -> str:
    """Render markdown to an HTML fragment safe to splice into the page.

    QTextDocument does the parsing, so there is no markdown dependency. Its
    output is a whole document, and only the body content is wanted here.
    """
    if not text:
        return ""
    doc = QTextDocument()
    doc.setMarkdown(_close_fences(text), MARKDOWN_FLAGS)
    html_text = doc.toHtml()

    start = html_text.find("<body")
    if start == -1:
        return html.escape(text).replace("\n", "<br>")
    start = html_text.find(">", start)
    end = html_text.rfind("</body>")
    if start == -1 or end == -1:
        return html.escape(text).replace("\n", "<br>")
    fragment = html_text[start + 1 : end].strip()

    # A reply could contain [x](x-llamachat-reasoning:0), which markdown
    # turns into a real anchor. Defuse those so only the toggles this code
    # emits can drive the UI. Search results are untrusted in the same way,
    # so their scheme is defused too.
    fragment = fragment.replace(f'href="{REASONING_SCHEME}', 'href="blocked:')
    fragment = fragment.replace(f'href="{SEARCH_SCHEME}', 'href="blocked:')

    # Tint code blocks, which Qt leaves unstyled. Qt emits one <pre> per
    # line, so the tint has to land on every one of them to read as a block.
    return _PRE_RE.sub(_tint_pre, fragment)


_PRE_RE = re.compile(r"<pre(\s+style=\"([^\"]*)\")?\s*>")


def _tint_pre(match: re.Match) -> str:
    existing = (match.group(2) or "").strip()
    if existing and not existing.endswith(";"):
        existing += ";"
    return f'<pre style="{existing}{_PRE_STYLE}">'


def _column(row, name: str, default: str = "") -> str:
    """Read a column that may predate a schema migration."""
    try:
        value = row[name]
    except (IndexError, KeyError):
        return default
    return value if value is not None else default


def _strip_marker(name: str) -> str:
    return name.replace(" 👁", "").strip()


def _plain(text: str) -> str:
    """Flatten an FTS snippet into one line of plain text."""
    return (
        text.replace("<b>", "").replace("</b>", "").replace("\n", " ")[:80]
    )


def _reasoning_html(text: str, index: int, expanded: bool, live: bool) -> str:
    """The thinking block: a clickable summary line, body only when open.

    `index` identifies which bubble the toggle link belongs to, so several
    replies in one transcript expand independently.
    """
    if not text:
        return ""
    lines = text.count("\n") + 1
    arrow = "▾" if expanded else "▸"
    label = "thinking…" if live else f"thinking ({len(text)} chars, {lines} lines)"
    header = (
        f'<a href="{REASONING_SCHEME}{index}" style="color:palette(mid);'
        f'text-decoration:none">{arrow} {label}</a>'
    )
    if not expanded:
        return f'<div style="margin:2px 0">{header}</div>'
    body = html.escape(text).replace("\n", "<br>")
    return (
        f'<div style="margin:2px 0">{header}</div>'
        f'<div style="margin:2px 0 6px 14px;color:palette(mid);'
        f'font-style:italic">{body}</div>'
    )


def _date_note(now: datetime.datetime | None = None) -> str:
    """Tell the model today's date, and not to date its own queries.

    A model has no clock and falls back on its training cutoff, which it
    then writes into the search query itself ("... 2025"). That poisons the
    results before they are fetched, so no amount of "do not guess" in a
    prompt reaches it. Giving it the real date is what actually fixes it.

    Only added when search is on: without it the model cannot check
    anything, and a date it cannot act on invites more confident guessing
    rather than less.
    """
    now = now or datetime.datetime.now().astimezone()
    return (
        f"Today's date is {now.strftime('%A, %d %B %Y')}. Your training data "
        "ends well before this. For anything that changes over time, search "
        "rather than answering from memory, and state what the results say "
        "rather than what you remember. Do not put a year in a search query "
        "unless the user asked about a specific year."
    )


def _searches(stored: str) -> list[dict]:
    """Decode the stored searches column into a list, tolerating anything."""
    if not stored:
        return []
    try:
        value = json.loads(stored)
    except ValueError:
        return []
    return value if isinstance(value, list) else []


def _search_html(records: list[dict], index: int, expanded: bool) -> str:
    """The search block: a summary line, sources only when open.

    Every result string is escaped. These are web pages the model chose to
    look at, so nothing here is trusted to be well-formed or well-meant.
    """
    if not records:
        return ""

    arrow = "▾" if expanded else "▸"
    lines = []
    for record in records:
        query = html.escape(str(record.get("query", "")))
        error = record.get("error")
        if error:
            lines.append(f"search failed: {query} — {html.escape(str(error))}")
        else:
            count = len(record.get("results") or [])
            lines.append(f"searched: {query} ({count} results)")
    summary = "; ".join(lines)
    header = (
        f'<a href="{SEARCH_SCHEME}{index}" style="color:palette(mid);'
        f'text-decoration:none">{arrow} {summary}</a>'
    )
    if not expanded:
        return f'<div style="margin:2px 0">{header}</div>'

    body = []
    for record in records:
        for result in record.get("results") or []:
            title = html.escape(str(result.get("title") or "(untitled)"))
            url = str(result.get("url") or "")
            snippet = html.escape(str(result.get("content") or ""))
            # Only real web schemes become anchors; the click handler
            # allowlists them again before anything is opened.
            if url.startswith(("http://", "https://")):
                safe = html.escape(url, quote=True)
                link = f'<a href="{safe}">{html.escape(url)}</a>'
            else:
                link = html.escape(url)
            body.append(
                f'<div style="margin:0 0 4px 0"><b>{title}</b><br>'
                f'{link}<br>{snippet}</div>'
            )
    if not body:
        body.append("<div>no results</div>")
    return (
        f'<div style="margin:2px 0">{header}</div>'
        f'<div style="margin:2px 0 6px 14px;color:palette(mid)">'
        f'{"".join(body)}</div>'
    )


def _bubble_html(
    role: str,
    text: str,
    attachments: list[Attachment] | None = None,
    saved_rows=None,
    reasoning: str = "",
    index: int = 0,
    expanded: bool = False,
    live: bool = False,
    searches: list[dict] | None = None,
    searches_expanded: bool = False,
) -> str:
    """One transcript entry as HTML."""
    label = {"user": "You", "assistant": "Model"}.get(role, role)
    colour = "palette(highlight)" if role == "user" else "palette(mid)"

    # Replies are markdown; what the user typed is shown as typed, so an
    # attached file's contents cannot be reflowed into headings and lists.
    if role == "assistant":
        body = _markdown_to_fragment(text)
    else:
        body = html.escape(text).replace("\n", "<br>")

    files = ""
    if attachments:
        names = ", ".join(html.escape(a.path.name) for a in attachments)
        files = f"<br><i>files: {names}</i>"
    elif saved_rows:
        parts = []
        for row in saved_rows:
            missing = "" if Path(row["path"]).exists() else " (missing)"
            parts.append(html.escape(row["path"]) + missing)
        files = f"<br><i>files: {', '.join(parts)}</i>"

    think = _reasoning_html(reasoning, index, expanded, live)
    # Above the thinking block: the search happened before the model reasoned
    # about what it found.
    found = _search_html(searches or [], index, searches_expanded)

    if role == "assistant":
        # Markdown produces block elements, so the speaker label sits on its
        # own line rather than trying to lead the first paragraph.
        return (
            f'<div style="margin:6px 0">{found}{think}'
            f'<div style="color:{colour}"><b>{label}:</b></div>'
            f"{body}{files}</div>"
        )
    return (
        f'<div style="margin:6px 0">{found}{think}'
        f'<b style="color:{colour}">{label}:</b> {body}{files}</div>'
    )
