Source code for editor.ui.chat_dock

"""AI Chat dock widget for the AxisPy Engine editor."""
from __future__ import annotations

import os
import re
import threading
from PyQt6.QtWidgets import (
    QDockWidget, QWidget, QVBoxLayout, QHBoxLayout,
    QTextEdit, QPlainTextEdit, QPushButton, QLabel,
    QScrollArea, QFrame, QApplication, QSizePolicy,
    QComboBox, QMenu, QInputDialog, QMessageBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve
from PyQt6.QtGui import (
    QFont, QColor, QTextCharFormat, QTextCursor,
    QPalette, QSyntaxHighlighter,
)
from PyQt6.QtWidgets import QGraphicsOpacityEffect
import qtawesome as qta
from editor.ui.engine_settings import theme_icon_color


def _markdown_to_html(text: str, is_dark: bool = True) -> str:
    """Convert basic Markdown to Qt HTML subset."""
    import html

    # Process tables first and store them
    tables = []
    
    def _extract_tables(match):
        """Extract table and replace with placeholder."""
        tables.append(match.group(0))
        return f"\n___TABLE_{len(tables)-1}___\n"
    
    # Match table blocks (lines starting and ending with |)
    def _process_tables(txt: str) -> str:
        lines = txt.split('\n')
        result_lines = []
        i = 0
        table_html_list = []
        
        while i < len(lines):
            line = lines[i]
            # Check if this is a table row
            if line.strip().startswith('|') and line.strip().endswith('|'):
                # Collect all table rows
                table_lines = []
                while i < len(lines) and lines[i].strip().startswith('|') and lines[i].strip().endswith('|'):
                    table_lines.append(lines[i])
                    i += 1
                
                if len(table_lines) >= 2:
                    # Parse table
                    header_row = table_lines[0]
                    separator_row = table_lines[1]
                    data_rows = table_lines[2:] if len(table_lines) > 2 else []
                    
                    # Check if separator row is valid
                    is_valid_separator = all(c in '|-:\t ' for c in separator_row.strip())
                    
                    if is_valid_separator:
                        # Build HTML table
                        border_color = '#555' if is_dark else '#ccc'
                        bg_color = '#2a2a3a' if is_dark else '#f5f5f5'
                        html_table = f'<table style="border-collapse:collapse;margin:8px 0;border:1px solid {border_color};">'
                        
                        # Header
                        html_table += '<thead>'
                        html_table += _parse_table_row(header_row, True, border_color, bg_color)
                        html_table += '</thead>'
                        
                        # Body
                        if data_rows:
                            html_table += '<tbody>'
                            for row in data_rows:
                                html_table += _parse_table_row(row, False, border_color, bg_color)
                            html_table += '</tbody>'
                        
                        html_table += '</table>'
                        table_html_list.append(html_table)
                        # Use HTML comment as placeholder - won't be affected by markdown
                        result_lines.append(f"<!--TABLE{len(table_html_list)-1}-->")
                        continue
                
                # Not a valid table, add lines back
                for tl in table_lines:
                    result_lines.append(tl)
                continue
            else:
                result_lines.append(line)
                i += 1
        
        return '\n'.join(result_lines), table_html_list
    
    text, table_html_list = _process_tables(text)
    
    # Escape HTML first
    text = html.escape(text)
    # Convert **bold** and __bold__
    text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
    text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
    # Convert *italic* and _italic_
    text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
    text = re.sub(r'_(.+?)_', r'<i>\1</i>', text)
    # Convert `code` spans
    code_color = '#e6c07b' if is_dark else '#c7254e'
    text = re.sub(r'`([^`]+)`', rf'<code style="background:rgba(255,255,255,0.1);color:{code_color};padding:1px 4px;border-radius:3px;font-family:Consolas,monospace;">\1</code>', text)
    # Convert [text](url) links
    text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
    # Convert ### headings
    text = re.sub(r'### (.+)$', r'<h3>\1</h3>', text, flags=re.MULTILINE)
    text = re.sub(r'## (.+)$', r'<h2>\1</h2>', text, flags=re.MULTILINE)
    text = re.sub(r'# (.+)$', r'<h1>\1</h1>', text, flags=re.MULTILINE)
    # Convert - and * list items
    text = re.sub(r'^\s*[-\*] (.+)$', r'• \1<br/>', text, flags=re.MULTILINE)
    # Preserve line breaks
    text = text.replace('\n', '<br/>')
    
    # Restore tables (unescaped)
    for i, table_html in enumerate(table_html_list):
        text = text.replace(f"&lt;!--TABLE{i}--&gt;", table_html)
    
    return text


def _parse_table_row(row: str, is_header: bool, border_color: str, bg_color: str) -> str:
    """Parse a markdown table row into HTML <tr>."""
    cells = row.strip('|').split('|')
    tag = 'th' if is_header else 'td'
    padding = '8px 12px'
    font_weight = 'bold' if is_header else 'normal'
    bg = bg_color if is_header else 'transparent'
    
    html_row = f'<tr style="border-bottom:1px solid {border_color};">'
    for cell in cells:
        content = cell.strip()
        # Apply inline markdown formatting to cell content
        content = _format_inline_markdown(content)
        html_row += f'<{tag} style="padding:{padding};font-weight:{font_weight};background:{bg};text-align:left;">{content}</{tag}>'
    html_row += '</tr>'
    return html_row


def _format_inline_markdown(text: str) -> str:
    """Apply inline markdown formatting (bold, italic, code, links)."""
    import html
    # Escape HTML first
    text = html.escape(text)
    # Convert **bold** and __bold__
    text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
    text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
    # Convert *italic* and _italic_
    text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
    text = re.sub(r'_(.+?)_', r'<i>\1</i>', text)
    # Convert `code` spans
    text = re.sub(r'`([^`]+)`', r'<code style="background:rgba(255,255,255,0.15);color:#e6c07b;padding:1px 4px;border-radius:3px;font-family:Consolas,monospace;font-size:12px;">\1</code>', text)
    # Convert [text](url) links
    text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2" style="color:#7ab3ef;text-decoration:underline;">\1</a>', text)
    return text


# ------------------------------------------------------------------
# Minimal Python syntax highlighter for code blocks
# ------------------------------------------------------------------

class _PythonHighlighter(QSyntaxHighlighter):
    """Very small highlighter used inside code-block widgets."""

    KEYWORDS = {
        "def", "class", "return", "if", "elif", "else", "for", "while",
        "import", "from", "as", "with", "try", "except", "finally",
        "raise", "yield", "pass", "break", "continue", "and", "or",
        "not", "in", "is", "None", "True", "False", "self", "lambda",
    }

    def __init__(self, parent=None):
        super().__init__(parent)
        self._formats = {}
        kw_fmt = QTextCharFormat()
        kw_fmt.setForeground(QColor("#c678dd"))
        kw_fmt.setFontWeight(QFont.Weight.Bold)
        self._formats["keyword"] = kw_fmt

        str_fmt = QTextCharFormat()
        str_fmt.setForeground(QColor("#98c379"))
        self._formats["string"] = str_fmt

        comment_fmt = QTextCharFormat()
        comment_fmt.setForeground(QColor("#5c6370"))
        self._formats["comment"] = comment_fmt

        num_fmt = QTextCharFormat()
        num_fmt.setForeground(QColor("#d19a66"))
        self._formats["number"] = num_fmt

        func_fmt = QTextCharFormat()
        func_fmt.setForeground(QColor("#61afef"))
        self._formats["function"] = func_fmt

    def highlightBlock(self, text: str):
        # Comments
        idx = text.find("#")
        if idx >= 0:
            self.setFormat(idx, len(text) - idx, self._formats["comment"])

        # Keywords
        for match in re.finditer(r"\b(" + "|".join(self.KEYWORDS) + r")\b", text):
            self.setFormat(match.start(), match.end() - match.start(), self._formats["keyword"])

        # Strings (simple)
        for match in re.finditer(r'(\".*?\"|\'.*?\')', text):
            self.setFormat(match.start(), match.end() - match.start(), self._formats["string"])

        # Numbers
        for match in re.finditer(r"\b\d+\.?\d*\b", text):
            self.setFormat(match.start(), match.end() - match.start(), self._formats["number"])

        # Function definitions
        for match in re.finditer(r"\bdef\s+(\w+)", text):
            self.setFormat(match.start(1), match.end(1) - match.start(1), self._formats["function"])


# ------------------------------------------------------------------
# Diff display widget
# ------------------------------------------------------------------

[docs] class DiffWidget(QFrame): """A collapsible unified diff widget for showing script edits.""" def __init__(self, path: str, old_text: str, new_text: str, parent=None): super().__init__(parent) self._path = path self._old_text = old_text self._new_text = new_text self._collapsed = False is_dark = QApplication.palette().color(QPalette.ColorRole.Window).lightness() < 128 border = "#444" if is_dark else "#ccc" self.setStyleSheet(f""" DiffWidget {{ background: {'#1e1e2e' if is_dark else '#fafafa'}; border: 1px solid {border}; border-radius: 6px; }} """) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # ---- Header bar ---- header = QFrame() header.setStyleSheet(f""" QFrame {{ background: {'#2a2a3a' if is_dark else '#e8e8f0'}; border-top-left-radius: 6px; border-top-right-radius: 6px; border-bottom: 1px solid {border}; }} """) h_layout = QHBoxLayout(header) h_layout.setContentsMargins(10, 6, 10, 6) h_layout.setSpacing(6) edit_icon = QLabel() edit_icon.setPixmap(qta.icon("fa5s.file-code", color="#7ab3ef").pixmap(14, 14)) h_layout.addWidget(edit_icon) path_label = QLabel(f"<b style='color:#7ab3ef;'>{path}</b>") path_label.setTextFormat(Qt.TextFormat.RichText) path_label.setStyleSheet(f"color: {'#aaa' if is_dark else '#555'}; font-size: 12px;") h_layout.addWidget(path_label) h_layout.addStretch() # Stats label (added/removed counts) old_lines = old_text.splitlines() if old_text.strip() else [] new_lines = new_text.splitlines() if new_text.strip() else [] stats_text = f"<span style='color:#ff6b6b;'>−{len(old_lines)}</span>&nbsp;&nbsp;<span style='color:#6bff6b;'>+{len(new_lines)}</span>" stats_label = QLabel(stats_text) stats_label.setTextFormat(Qt.TextFormat.RichText) stats_label.setStyleSheet("font-size: 11px; font-family: Consolas;") h_layout.addWidget(stats_label) # Copy diff button copy_btn = QPushButton() copy_btn.setIcon(qta.icon("fa5s.copy", color="#888")) copy_btn.setToolTip("Copy diff") copy_btn.setFixedSize(22, 22) copy_btn.setCursor(Qt.CursorShape.PointingHandCursor) copy_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; border-radius: 3px; } QPushButton:hover { background: rgba(255,255,255,30); } """) copy_btn.clicked.connect(self._copy_diff) h_layout.addWidget(copy_btn) # Collapse/expand button self._toggle_btn = QPushButton() self._toggle_btn.setIcon(qta.icon("fa5s.chevron-up", color="#888")) self._toggle_btn.setToolTip("Collapse") self._toggle_btn.setFixedSize(22, 22) self._toggle_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._toggle_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; border-radius: 3px; } QPushButton:hover { background: rgba(255,255,255,30); } """) self._toggle_btn.clicked.connect(self._toggle_collapse) h_layout.addWidget(self._toggle_btn) layout.addWidget(header) # ---- Diff body ---- self._body = QFrame() body_layout = QVBoxLayout(self._body) body_layout.setContentsMargins(0, 0, 0, 0) body_layout.setSpacing(0) diff_lines = self._build_unified_diff(old_text, new_text, is_dark) for line_widget in diff_lines: body_layout.addWidget(line_widget) layout.addWidget(self._body) def _build_unified_diff(self, old_text: str, new_text: str, is_dark: bool) -> list: """Build unified diff line widgets.""" import difflib old_lines = old_text.splitlines(keepends=True) new_lines = new_text.splitlines(keepends=True) diff = list(difflib.unified_diff(old_lines, new_lines, n=3)) widgets = [] if not diff: lbl = QLabel(" No differences detected") lbl.setStyleSheet(f"color: {'#888' if is_dark else '#666'}; padding: 8px; font-size: 12px; font-family: Consolas;") widgets.append(lbl) return widgets line_num = 0 for raw_line in diff: line = raw_line.rstrip('\n').rstrip('\r') # Skip diff headers (---, +++, @@) if line.startswith('---') or line.startswith('+++'): continue if line.startswith('@@'): # Hunk header w = self._make_line_widget(line, 'hunk', is_dark) widgets.append(w) continue line_num += 1 if line.startswith('-'): w = self._make_line_widget(line, 'removed', is_dark) elif line.startswith('+'): w = self._make_line_widget(line, 'added', is_dark) else: w = self._make_line_widget(line, 'context', is_dark) widgets.append(w) # Limit displayed lines and add a "show more" indicator if needed max_lines = 40 if len(widgets) > max_lines: truncated = widgets[:max_lines] more_lbl = QLabel(f" ... and {len(widgets) - max_lines} more lines") more_lbl.setStyleSheet(f"color: {'#888' if is_dark else '#666'}; padding: 4px 8px; font-size: 11px; font-style: italic; font-family: Consolas;") truncated.append(more_lbl) return truncated return widgets @staticmethod def _make_line_widget(text: str, kind: str, is_dark: bool) -> QLabel: """Create a styled QLabel for a single diff line.""" import html as html_mod escaped = html_mod.escape(text) if kind == 'removed': bg = '#3a1f1f' if is_dark else '#ffeef0' fg = '#ff9999' if is_dark else '#b31d28' prefix_color = '#ff6b6b' if is_dark else '#cb2431' elif kind == 'added': bg = '#1f3a1f' if is_dark else '#e6ffec' fg = '#99ff99' if is_dark else '#22863a' prefix_color = '#6bff6b' if is_dark else '#28a745' elif kind == 'hunk': bg = '#252540' if is_dark else '#f1f1ff' fg = '#8888cc' if is_dark else '#6f42c1' prefix_color = fg else: bg = 'transparent' fg = '#aaa' if is_dark else '#444' prefix_color = fg # Color the prefix character differently if kind in ('removed', 'added') and len(escaped) > 0: display = f"<span style='color:{prefix_color};font-weight:bold;'>{escaped[0]}</span>{escaped[1:]}" elif kind == 'hunk': display = f"<span style='color:{prefix_color};'>{escaped}</span>" else: display = f" {escaped}" lbl = QLabel(f"<pre style='margin:0;padding:0;font-family:Consolas,monospace;font-size:12px;white-space:pre;'>{display}</pre>") lbl.setTextFormat(Qt.TextFormat.RichText) lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) lbl.setStyleSheet(f""" QLabel {{ background: {bg}; color: {fg}; padding: 1px 8px; border: none; min-height: 18px; }} """) return lbl def _copy_diff(self): """Copy the full diff text to clipboard.""" import difflib old_lines = self._old_text.splitlines(keepends=True) new_lines = self._new_text.splitlines(keepends=True) diff = difflib.unified_diff(old_lines, new_lines, fromfile=self._path, tofile=self._path) clipboard = QApplication.clipboard() if clipboard: clipboard.setText(''.join(diff)) # Show toast on parent ChatMessageWidget parent = self.parent() if hasattr(parent, '_show_copy_toast'): parent._show_copy_toast() def _toggle_collapse(self): """Toggle the diff body visibility.""" self._collapsed = not self._collapsed self._body.setVisible(not self._collapsed) if self._collapsed: self._toggle_btn.setIcon(qta.icon("fa5s.chevron-down", color="#888")) self._toggle_btn.setToolTip("Expand") else: self._toggle_btn.setIcon(qta.icon("fa5s.chevron-up", color="#888")) self._toggle_btn.setToolTip("Collapse")
# ------------------------------------------------------------------ # Chat message widget # ------------------------------------------------------------------
[docs] class ChatMessageWidget(QFrame): """A single chat message bubble.""" copy_code_requested = pyqtSignal(str) revert_requested = pyqtSignal(int) # prompt_index def __init__(self, role: str, content: str, prompt_index: int = -1, parent=None): super().__init__(parent) self.role = role self.content = content self.prompt_index = prompt_index self._revert_btn = None is_dark = QApplication.palette().color(QPalette.ColorRole.Window).lightness() < 128 if role == "user": bg = "#2b3d4f" if is_dark else "#dce8f5" fg = "#e0e0e0" if is_dark else "#1a1a1a" else: bg = "#1e2a1e" if is_dark else "#e8f5e8" fg = "#e0e0e0" if is_dark else "#1a1a1a" self.setStyleSheet(f""" ChatMessageWidget {{ background-color: {bg}; border-radius: 8px; padding: 8px; }} """) layout = QVBoxLayout(self) layout.setContentsMargins(10, 6, 10, 6) layout.setSpacing(4) # Profile Header header_layout = QHBoxLayout() header_layout.setContentsMargins(0, 0, 0, 0) icon_label = QLabel() c = "#7ab3ef" if role != "user" else "#a0c0e0" icon_name = "fa5s.robot" if role != "user" else "fa5s.user" icon_label.setPixmap(qta.icon(icon_name, color=c).pixmap(16, 16)) role_name_label = QLabel("You" if role == "user" else "Axis AI Assistant") role_name_label.setStyleSheet(f"color: {c}; font-weight: bold; font-size: 13px;") import datetime time_label = QLabel(datetime.datetime.now().strftime("%H:%M")) time_label.setStyleSheet(f"color: {'#888' if is_dark else '#666'}; font-size: 10px;") copy_msg_btn = QPushButton() copy_msg_btn.setIcon(qta.icon("fa5s.copy", color="#666")) copy_msg_btn.setToolTip("Copy message") copy_msg_btn.setFixedSize(20, 20) copy_msg_btn.setCursor(Qt.CursorShape.PointingHandCursor) copy_msg_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; border-radius: 3px; } QPushButton:hover { background: rgba(255,255,255,30); } """) copy_msg_btn.clicked.connect(self._copy_message) header_layout.addWidget(icon_label) header_layout.addWidget(role_name_label) header_layout.addStretch() header_layout.addWidget(time_label) header_layout.addWidget(copy_msg_btn) # Revert button (hidden by default, shown when prompt had file actions) if role != "user": self._revert_btn = QPushButton() self._revert_btn.setIcon(qta.icon("fa5s.undo-alt", color="#e0a050")) self._revert_btn.setToolTip("Revert changes from this response") self._revert_btn.setFixedSize(20, 20) self._revert_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._revert_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; border-radius: 3px; } QPushButton:hover { background: rgba(255,200,100,40); } """) self._revert_btn.clicked.connect(self._on_revert_clicked) self._revert_btn.hide() header_layout.addWidget(self._revert_btn) layout.addLayout(header_layout) # Parse content for code blocks self._add_content(layout, content, fg, is_dark) def _add_content(self, layout: QVBoxLayout, content: str, fg: str, is_dark: bool): """Parse markdown-like content and add text/code widgets.""" parts = re.split(r"(```[\s\S]*?```)", content) for part in parts: if part.startswith("```") and part.endswith("```"): # Code block code = part[3:] lang = "" if "\n" in code: first_line, rest = code.split("\n", 1) lang = first_line.strip() code = rest elif "\r\n" in code: first_line, rest = code.split("\r\n", 1) lang = first_line.strip() code = rest code = code.rstrip("`").rstrip() code_frame = QFrame() code_frame.setStyleSheet(f""" QFrame {{ background-color: {'#1a1a2e' if is_dark else '#f0f0f0'}; border: 1px solid {'#333' if is_dark else '#ccc'}; border-radius: 4px; }} """) code_layout = QVBoxLayout(code_frame) code_layout.setContentsMargins(8, 4, 8, 4) code_layout.setSpacing(2) # Copy button + Language Label btn_row = QHBoxLayout() if lang: lang_label = QLabel(lang) lang_label.setStyleSheet(f"color: {'#7ab3ef' if is_dark else '#2a4a6b'}; font-size: 10px; font-weight: bold; font-family: Consolas;") btn_row.addWidget(lang_label) btn_row.addStretch() copy_btn = QPushButton("Copy") copy_btn.setFixedHeight(20) copy_btn.setStyleSheet(""" QPushButton { background: transparent; border: 1px solid #555; border-radius: 3px; padding: 1px 8px; font-size: 10px; color: #aaa; } QPushButton:hover { background: #333; color: #fff; } """) captured_code = code copy_btn.clicked.connect(lambda checked, c=captured_code: self._copy_code(c)) btn_row.addWidget(copy_btn) code_layout.addLayout(btn_row) code_edit = QPlainTextEdit() code_edit.setPlainText(code) code_edit.setReadOnly(True) code_edit.setFont(QFont("Consolas", 10)) code_edit.setStyleSheet(f""" QPlainTextEdit {{ background: transparent; color: {'#abb2bf' if is_dark else '#333'}; border: none; }} """) # Auto-size height doc = code_edit.document() doc.setDefaultFont(code_edit.font()) height = int(doc.size().height()) + 10 code_edit.setFixedHeight(min(height, 400)) _PythonHighlighter(code_edit.document()) code_layout.addWidget(code_edit) layout.addWidget(code_frame) else: # Markdown text text = part.strip() if text: html = _markdown_to_html(text, is_dark) label = QLabel(html) label.setWordWrap(True) label.setTextFormat(Qt.TextFormat.RichText) label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.LinksAccessibleByMouse ) label.setOpenExternalLinks(True) label.setStyleSheet(f""" QLabel {{ color: {fg}; font-size: 13px; line-height: 1.4; }} QLabel a {{ color: #7ab3ef; text-decoration: underline; }} QLabel code {{ font-family: Consolas, monospace; }} """) layout.addWidget(label)
[docs] def show_revert_button(self): """Make the revert button visible (called when prompt had file actions).""" if self._revert_btn: self._revert_btn.show()
[docs] def mark_reverted(self): """Visually mark this message as reverted.""" if self._revert_btn: self._revert_btn.setIcon(qta.icon("fa5s.check", color="#888")) self._revert_btn.setToolTip("Changes reverted") self._revert_btn.setEnabled(False) self._revert_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; border-radius: 3px; } """)
def _on_revert_clicked(self): """Emit revert signal with this message's prompt index.""" if self.prompt_index >= 0: self.revert_requested.emit(self.prompt_index) def _copy_message(self): """Copy the entire message content to clipboard.""" clipboard = QApplication.clipboard() if clipboard: clipboard.setText(self.content) self._show_copy_toast() def _copy_code(self, code: str): clipboard = QApplication.clipboard() if clipboard: clipboard.setText(code) self._show_copy_toast() self.copy_code_requested.emit(code) def _show_copy_toast(self): """Show a small animated 'Copied!' toast over this message.""" self._show_copy_toast_text("✓ Copied!") def _show_copy_toast_text(self, text: str): """Show a small animated toast with custom text over this message.""" toast = QLabel(text, self) toast.setStyleSheet(""" QLabel { background: #2a2a3a; color: #7ab3ef; border: 1px solid #555; border-radius: 4px; padding: 4px 12px; font-size: 11px; font-weight: bold; } """) toast.adjustSize() toast.move(self.width() - toast.width() - 10, 4) toast.show() effect = QGraphicsOpacityEffect(toast) toast.setGraphicsEffect(effect) effect.setOpacity(1.0) anim = QPropertyAnimation(effect, b"opacity", toast) anim.setDuration(1200) anim.setStartValue(1.0) anim.setKeyValueAt(0.6, 1.0) anim.setEndValue(0.0) anim.setEasingCurve(QEasingCurve.Type.InQuad) anim.finished.connect(toast.deleteLater) anim.start()
[docs] def update_content(self, content: str): """Replace content (used during streaming).""" self.content = content layout = self.layout() # Preserve DiffWidgets before clearing diff_widgets = [] i = 1 while i < layout.count(): item = layout.itemAt(i) widget = item.widget() if isinstance(widget, DiffWidget): layout.takeAt(i) diff_widgets.append(widget) else: i += 1 # Clear existing widgets except role label (index 0) while layout.count() > 1: item = layout.takeAt(1) widget = item.widget() if widget: widget.deleteLater() is_dark = QApplication.palette().color(QPalette.ColorRole.Window).lightness() < 128 fg = "#e0e0e0" if is_dark else "#1a1a1a" self._add_content(layout, content, fg, is_dark) # Re-add DiffWidgets at the bottom for diff_w in diff_widgets: layout.addWidget(diff_w)
[docs] def inject_diff_widget(self, path: str, old_text: str, new_text: str): """Inject a DiffWidget into the message.""" diff_w = DiffWidget(path, old_text, new_text, parent=self) self.layout().addWidget(diff_w)
# ------------------------------------------------------------------ # Main chat dock # ------------------------------------------------------------------
[docs] class ChatDock(QDockWidget): """Dockable AI chat panel for the editor.""" message_received = pyqtSignal(str) def __init__(self, chat_manager, parent=None): super().__init__("AI Assistant", parent) self.setObjectName("ChatDock") self.chat_manager = chat_manager self._streaming = False self._stream_widget: ChatMessageWidget | None = None self._stream_text = "" self._pending_chunks: list[str] = [] self._chunk_timer = QTimer(self) self._chunk_timer.setInterval(50) self._chunk_timer.timeout.connect(self._flush_chunks) self._current_prompt_index = -1 self._stream_had_actions = False self._setup_ui() def _setup_ui(self): main_widget = QWidget() main_layout = QVBoxLayout(main_widget) main_layout.setContentsMargins(4, 4, 4, 4) main_layout.setSpacing(4) # Header with session selector, model info, and clear button header = QHBoxLayout() c = theme_icon_color() # Session selector self._session_combo = QComboBox() self._session_combo.setFixedWidth(140) self._session_combo.setToolTip("Select conversation session") self._session_combo.currentIndexChanged.connect(self._on_session_changed) header.addWidget(self._session_combo) # New session button new_btn = QPushButton() new_btn.setIcon(qta.icon("fa5s.plus", color=c)) new_btn.setToolTip("New session") new_btn.setFixedSize(24, 24) new_btn.setStyleSheet("QPushButton { background: transparent; border: none; border-radius: 4px; } QPushButton:hover { background: rgba(255,255,255,30); }") new_btn.clicked.connect(self._on_new_session) header.addWidget(new_btn) # Delete session button del_btn = QPushButton() del_btn.setIcon(qta.icon("fa5s.trash", color=c)) del_btn.setToolTip("Delete session") del_btn.setFixedSize(24, 24) del_btn.setStyleSheet("QPushButton { background: transparent; border: none; border-radius: 4px; } QPushButton:hover { background: rgba(255,255,255,30); }") del_btn.clicked.connect(self._on_delete_session) header.addWidget(del_btn) header.addSpacing(10) self._model_label = QLabel("No provider") self._model_label.setStyleSheet("color: #888; font-size: 11px;") header.addWidget(self._model_label) header.addStretch() clear_btn = QPushButton() clear_btn.setIcon(qta.icon("fa5s.trash-alt", color=c)) clear_btn.setToolTip("Clear conversation") clear_btn.setFixedSize(28, 28) clear_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; border-radius: 4px; } QPushButton:hover { background: rgba(255,255,255,30); } """) clear_btn.clicked.connect(self._clear_chat) header.addWidget(clear_btn) main_layout.addLayout(header) # Scroll area for messages self._scroll_area = QScrollArea() self._scroll_area.setWidgetResizable(True) self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._scroll_area.setStyleSheet("QScrollArea { border: none; }") self._messages_widget = QWidget() self._messages_layout = QVBoxLayout(self._messages_widget) self._messages_layout.setContentsMargins(2, 2, 2, 2) self._messages_layout.setSpacing(8) # Empty state self._empty_state_frame = QFrame() empty_layout = QVBoxLayout(self._empty_state_frame) empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) empty_icon = QLabel() empty_icon.setPixmap(qta.icon("fa5s.robot", color="#666").pixmap(48, 48)) empty_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) empty_title = QLabel("Axis AI Assistant") empty_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #7ab3ef; margin-top: 10px;") empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter) empty_desc = QLabel("I can help you build your game, write scripts,\nand navigate the AxisPy Engine.") empty_desc.setStyleSheet("color: #888; font-size: 12px; margin-top: 5px;") empty_desc.setAlignment(Qt.AlignmentFlag.AlignCenter) empty_layout.addWidget(empty_icon) empty_layout.addWidget(empty_title) empty_layout.addWidget(empty_desc) self._messages_layout.addWidget(self._empty_state_frame) self._messages_layout.addStretch() self._scroll_area.setWidget(self._messages_widget) main_layout.addWidget(self._scroll_area, 1) # Input area input_frame = QFrame() input_frame.setStyleSheet(""" QFrame { border: 1px solid #444; border-radius: 6px; background: palette(base); } """) input_layout = QVBoxLayout(input_frame) input_layout.setContentsMargins(6, 6, 6, 6) input_layout.setSpacing(4) self._input_edit = QPlainTextEdit() self._input_edit.setPlaceholderText("Ask anything about your game project...") self._input_edit.setFont(QFont("Segoe UI", 11)) self._input_edit.setMaximumHeight(100) self._input_edit.setStyleSheet(""" QPlainTextEdit { border: none; background: transparent; } """) input_layout.addWidget(self._input_edit) # Bottom row: context toggle + send bottom_row = QHBoxLayout() bottom_row.setSpacing(6) self._context_check = QPushButton("Context: On") self._context_check.setCheckable(True) self._context_check.setChecked(True) self._context_check.setFixedHeight(28) self._context_check.setStyleSheet(""" QPushButton { background: transparent; border: 1px solid #555; border-radius: 4px; padding: 2px 10px; font-size: 11px; color: #aaa; } QPushButton:checked { color: #7ab3ef; border-color: #7ab3ef; } QPushButton:hover { background: rgba(255,255,255,20); } """) self._context_check.toggled.connect( lambda on: self._context_check.setText("Context: On" if on else "Context: Off") ) bottom_row.addWidget(self._context_check) bottom_row.addStretch() self._send_btn = QPushButton("Send") self._send_btn.setIcon(qta.icon("fa5s.paper-plane", color="#7ab3ef")) self._send_btn.setFixedHeight(30) self._send_btn.setStyleSheet(""" QPushButton { background: #2a4a6b; color: white; border: none; border-radius: 4px; padding: 4px 16px; font-weight: bold; } QPushButton:hover { background: #3a5a7b; } QPushButton:disabled { background: #333; color: #666; } """) self._send_btn.clicked.connect(self._on_send) bottom_row.addWidget(self._send_btn) input_layout.addLayout(bottom_row) main_layout.addWidget(input_frame) self.setWidget(main_widget) # Install Ctrl+Enter shortcut on input self._input_edit.installEventFilter(self)
[docs] def eventFilter(self, obj, event): if obj is self._input_edit and event.type() == event.Type.KeyPress: key = event.key() mods = event.modifiers() if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if mods & Qt.KeyboardModifier.ControlModifier: self._on_send() return True elif not (mods & Qt.KeyboardModifier.ShiftModifier): self._on_send() return True return super().eventFilter(obj, event)
[docs] def update_model_label(self): if self.chat_manager.provider and self.chat_manager.provider.is_available(): self._model_label.setText(f"Model: {self.chat_manager.provider.model_name()}") self._model_label.setStyleSheet("color: #7ab3ef; font-size: 11px;") else: self._model_label.setText("No AI provider configured") self._model_label.setStyleSheet("color: #888; font-size: 11px;")
# ------------------------------------------------------------------ # Send / receive # ------------------------------------------------------------------ def _on_send(self): if self._streaming: return text = self._input_edit.toPlainText().strip() if not text: return self._input_edit.clear() self._add_message("user", text) # Disable context if toggled off original_context = self.chat_manager.context_builder if not self._context_check.isChecked(): self.chat_manager.context_builder = type(original_context)() # Start streaming in a background thread self._streaming = True self._send_btn.setEnabled(False) self._send_btn.setText("● Thinking...") self._stream_text = "" self._pending_chunks = [] self._stream_had_actions = False # Create placeholder assistant message with prompt index self._current_prompt_index = self.chat_manager.current_prompt_index + 1 self._stream_widget = self._add_message("assistant", "...", prompt_index=self._current_prompt_index) self.chat_manager.set_callbacks( on_chunk=self._on_stream_chunk, on_complete=self._on_stream_complete, on_error=self._on_stream_error, on_session_changed=self.refresh_sessions, on_tool_result=self._on_tool_result, ) def _run(): try: self.chat_manager.send_message_stream(text) except Exception as e: self._pending_chunks.append(f"\n[Error] {e}") thread = threading.Thread(target=_run, daemon=True) thread.start() self._chunk_timer.start() # Restore context builder if not self._context_check.isChecked(): self.chat_manager.context_builder = original_context def _on_stream_chunk(self, chunk: str): """Called from background thread — append to pending list.""" self._pending_chunks.append(chunk) def _on_stream_complete(self, full_text: str): """Called from background thread when streaming is done.""" self._pending_chunks.append(None) # Sentinel def _on_stream_error(self, error: str): self._pending_chunks.append(error) self._pending_chunks.append(None) def _on_tool_result(self, tool_name: str, result: str): """Called when a tool execution completes — show diff for script edits.""" try: import json data = json.loads(result) except Exception: return # Track that this prompt had file-modifying actions modifying_tools = {"edit_script", "write_script", "create_entity", "add_component_to_entity", "modify_component"} if tool_name in modifying_tools and data.get("success"): self._stream_had_actions = True # Only show diff for edit_script with successful result if tool_name == "edit_script" and data.get("success") and data.get("diff"): diff = data["diff"] old_text = diff.get("old_text", "") new_text = diff.get("new_text", "") path = data.get("path", "") # Store diff data and schedule widget creation on main thread self._pending_diff = (path, old_text, new_text) QTimer.singleShot(0, self._inject_diff_widget) def _inject_diff_widget(self): """Create and inject a DiffWidget into the current stream widget (main thread).""" diff_data = getattr(self, '_pending_diff', None) if diff_data and self._stream_widget: path, old_text, new_text = diff_data self._stream_widget.inject_diff_widget(path, old_text, new_text) self._scroll_to_bottom() self._pending_diff = None def _flush_chunks(self): """Called by QTimer on the main thread — apply pending chunks to UI.""" if not self._pending_chunks: return chunks = list(self._pending_chunks) self._pending_chunks.clear() done = False for chunk in chunks: if chunk is None: done = True break self._stream_text += chunk if self._stream_widget: self._stream_widget.update_content(self._stream_text) self._scroll_to_bottom() if done: self._chunk_timer.stop() self._streaming = False self._send_btn.setEnabled(True) self._send_btn.setText("Send") # Show revert button if this prompt had file-modifying actions if self._stream_had_actions and self._stream_widget: self._stream_widget.show_revert_button() self._stream_widget = None # ------------------------------------------------------------------ # Message display # ------------------------------------------------------------------ def _add_message(self, role: str, content: str, prompt_index: int = -1) -> ChatMessageWidget: self._empty_state_frame.hide() msg_widget = ChatMessageWidget(role, content, prompt_index=prompt_index) msg_widget.revert_requested.connect(self._on_revert_prompt) # Insert before the stretch count = self._messages_layout.count() self._messages_layout.insertWidget(count - 1, msg_widget) QTimer.singleShot(10, self._scroll_to_bottom) return msg_widget def _scroll_to_bottom(self): vbar = self._scroll_area.verticalScrollBar() vbar.setValue(vbar.maximum()) def _on_revert_prompt(self, prompt_index: int): """Handle revert request from a message widget.""" tracker = self.chat_manager.action_tracker if tracker.is_reverted(prompt_index): return files = tracker.get_modified_files(prompt_index) if not files: return # Build a short file list for the confirmation dialog file_names = [os.path.basename(f) for f in files] file_list = "\n".join(f" • {n}" for n in file_names) reply = QMessageBox.question( self, "Revert AI Changes", f"Revert changes to {len(files)} file(s)?\n\n{file_list}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return results = self.chat_manager.revert_prompt(prompt_index) # Mark the widget as reverted sender = self.sender() if isinstance(sender, ChatMessageWidget): sender.mark_reverted() else: # Find the widget by prompt_index for i in range(self._messages_layout.count()): item = self._messages_layout.itemAt(i) w = item.widget() if item else None if isinstance(w, ChatMessageWidget) and w.prompt_index == prompt_index: w.mark_reverted() break # Reload scene if any scene files were reverted scene_reload_cb = self.chat_manager.tool_executor._scene_reload_callback if scene_reload_cb: for path, status in results.items(): if status == "restored" and path.endswith(".scene"): try: scene_reload_cb(path) except Exception: pass # Show confirmation toast on the reverted message widget for i in range(self._messages_layout.count()): item = self._messages_layout.itemAt(i) w = item.widget() if item else None if isinstance(w, ChatMessageWidget) and w.prompt_index == prompt_index: w._show_copy_toast_text("✓ Reverted!") break def _clear_chat(self): self.chat_manager.clear_history() self.chat_manager.action_tracker.clear() self._clear_message_widgets() self._empty_state_frame.show() def _clear_message_widgets(self): for i in reversed(range(self._messages_layout.count())): item = self._messages_layout.itemAt(i) if item.widget() and isinstance(item.widget(), ChatMessageWidget): widget = item.widget() self._messages_layout.removeItem(item) widget.deleteLater() # ------------------------------------------------------------------ # Session management # ------------------------------------------------------------------
[docs] def refresh_sessions(self): """Refresh the session dropdown from session_manager.""" self._session_combo.blockSignals(True) self._session_combo.clear() sessions = self.chat_manager.session_manager.get_session_list() active_id = self.chat_manager.session_manager.active_session_id for sess in sessions: self._session_combo.addItem(sess.name, sess.id) # Select active session for i in range(self._session_combo.count()): if self._session_combo.itemData(i) == active_id: self._session_combo.setCurrentIndex(i) break self._session_combo.blockSignals(False)
def _on_session_changed(self, index: int): """Handle session dropdown change.""" if index < 0: return session_id = self._session_combo.itemData(index) if session_id and session_id != self.chat_manager.session_manager.active_session_id: self.chat_manager.switch_session(session_id) self._reload_messages() def _on_new_session(self): """Create a new session.""" name, ok = QInputDialog.getText(self, "New Session", "Session name:", text="New Session") if ok: self.chat_manager.create_new_session(name) self.refresh_sessions() self._reload_messages() def _on_delete_session(self): """Delete the current session.""" current_id = self.chat_manager.session_manager.active_session_id if not current_id: return session = self.chat_manager.session_manager.sessions.get(current_id) if not session: return reply = QMessageBox.question( self, "Delete Session", f"Delete session \"{session.name}\"?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.chat_manager.delete_session(current_id) self.refresh_sessions() self._reload_messages() def _reload_messages(self): """Reload all messages from the active session.""" self._clear_message_widgets() has_messages = False # Load from history for msg in self.chat_manager.history: if msg.role in ("user", "assistant"): self._add_message(msg.role, msg.content) has_messages = True if has_messages: self._empty_state_frame.hide() else: self._empty_state_frame.show() self._scroll_to_bottom()