diff --git a/gui.py b/gui.py
index 6516256..953d540 100644
--- a/gui.py
+++ b/gui.py
@@ -6,15 +6,24 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer
from pygments.util import ClassNotFound
-from PyQt6.QtCore import QThread, QTimer, pyqtSignal
+from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import (
QColor,
QFont,
+ QIcon,
+ QLinearGradient,
+ QPainter,
+ QPainterPath,
+ QPalette,
+ QPixmap,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
QTextDocument,
QTextDocumentFragment,
+ QTextLength,
+ QTextTableCellFormat,
+ QTextTableFormat,
)
from PyQt6.QtWidgets import (
QApplication,
@@ -33,6 +42,198 @@ import chatbot
# How often the bottom-right GPU/VRAM readout refreshes.
GPU_STATUS_INTERVAL_MS = 1000
+# --- Visual theme ------------------------------------------------------
+# An explicit dark palette rather than relying on the OS theme, so the app
+# looks the same everywhere instead of "Qt follows Windows' dark app theme".
+BG_APP = "#0b0d12"
+BG_PANEL = "#12151c"
+BG_ELEVATED = "#181c25"
+BORDER = "#262b36"
+BORDER_STRONG = "#333949"
+TEXT_PRIMARY = "#e6e8ee"
+TEXT_SECONDARY = "#8b92a5"
+ACCENT = "#6366f1"
+ACCENT_HOVER = "#7b7ef4"
+ACCENT_PRESSED = "#4f52d6"
+
+# (accent, background) per chat role, used for the accent stripe and tinted
+# card background of each message.
+ROLE_STYLES = {
+ "user": ("#60a5fa", "#141b2b"),
+ "assistant": ("#34d399", "#0f211c"),
+ "system": ("#9aa1b4", "#161922"),
+}
+
+_STYLESHEET = f"""
+QMainWindow {{
+ background-color: {BG_APP};
+}}
+
+QWidget {{
+ color: {TEXT_PRIMARY};
+ font-family: "Segoe UI Variable Text", "Segoe UI", sans-serif;
+ font-size: 10.5pt;
+}}
+
+#header {{
+ border-bottom: 1px solid {BORDER};
+}}
+
+#titleLabel {{
+ font-size: 15pt;
+ font-weight: 600;
+ color: {TEXT_PRIMARY};
+}}
+
+#subtitleLabel {{
+ font-size: 9pt;
+ color: {TEXT_SECONDARY};
+}}
+
+#badge {{
+ background-color: {BG_ELEVATED};
+ border: 1px solid {BORDER};
+ border-radius: 11px;
+ padding: 4px 12px;
+ color: {TEXT_SECONDARY};
+ font-size: 9pt;
+}}
+
+QTextBrowser#chatView {{
+ background-color: {BG_PANEL};
+ border: 1px solid {BORDER};
+ border-radius: 14px;
+ padding: 10px;
+}}
+
+QLineEdit#messageInput {{
+ background-color: {BG_ELEVATED};
+ border: 1px solid {BORDER};
+ border-radius: 19px;
+ padding: 0 16px;
+ color: {TEXT_PRIMARY};
+ selection-background-color: {ACCENT};
+}}
+
+QLineEdit#messageInput:focus {{
+ border: 1px solid {ACCENT};
+}}
+
+QLineEdit#messageInput:disabled {{
+ color: {TEXT_SECONDARY};
+}}
+
+QPushButton#sendButton {{
+ background-color: {ACCENT};
+ color: #ffffff;
+ border: none;
+ border-radius: 19px;
+ padding: 0 22px;
+ font-weight: 600;
+}}
+
+QPushButton#sendButton:hover {{
+ background-color: {ACCENT_HOVER};
+}}
+
+QPushButton#sendButton:pressed {{
+ background-color: {ACCENT_PRESSED};
+}}
+
+QPushButton#sendButton:disabled {{
+ background-color: {BG_ELEVATED};
+ color: {TEXT_SECONDARY};
+ border: 1px solid {BORDER};
+}}
+
+QStatusBar {{
+ background-color: {BG_APP};
+ border-top: 1px solid {BORDER};
+ color: {TEXT_SECONDARY};
+ font-size: 9pt;
+}}
+
+QStatusBar::item {{
+ border: none;
+}}
+
+QScrollBar:vertical {{
+ background: transparent;
+ width: 10px;
+ margin: 4px 2px 4px 0;
+}}
+
+QScrollBar::handle:vertical {{
+ background: {BORDER_STRONG};
+ border-radius: 5px;
+ min-height: 24px;
+}}
+
+QScrollBar::handle:vertical:hover {{
+ background: {TEXT_SECONDARY};
+}}
+
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
+ height: 0;
+}}
+
+QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
+ background: transparent;
+}}
+
+QToolTip {{
+ background-color: {BG_ELEVATED};
+ color: {TEXT_PRIMARY};
+ border: 1px solid {BORDER};
+ padding: 4px 8px;
+}}
+"""
+
+
+def _dark_palette() -> QPalette:
+ """Baseline QPalette for the Fusion style, so anything the stylesheet
+ doesn't reach (native dialogs, menus) still matches the dark theme."""
+ palette = QPalette()
+ palette.setColor(QPalette.ColorRole.Window, QColor(BG_APP))
+ palette.setColor(QPalette.ColorRole.WindowText, QColor(TEXT_PRIMARY))
+ palette.setColor(QPalette.ColorRole.Base, QColor(BG_ELEVATED))
+ palette.setColor(QPalette.ColorRole.AlternateBase, QColor(BG_PANEL))
+ palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(BG_ELEVATED))
+ palette.setColor(QPalette.ColorRole.ToolTipText, QColor(TEXT_PRIMARY))
+ palette.setColor(QPalette.ColorRole.Text, QColor(TEXT_PRIMARY))
+ palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(TEXT_SECONDARY))
+ palette.setColor(QPalette.ColorRole.Button, QColor(BG_ELEVATED))
+ palette.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT_PRIMARY))
+ palette.setColor(QPalette.ColorRole.Highlight, QColor(ACCENT))
+ palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
+ return palette
+
+
+def _make_app_icon() -> QIcon:
+ """Small generated glyph for the window/taskbar icon, since the project
+ ships no icon asset."""
+ size = 64
+ pixmap = QPixmap(size, size)
+ pixmap.fill(QColor(0, 0, 0, 0))
+
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ gradient = QLinearGradient(0, 0, size, size)
+ gradient.setColorAt(0.0, QColor(ACCENT_HOVER))
+ gradient.setColorAt(1.0, QColor(ACCENT_PRESSED))
+
+ path = QPainterPath()
+ path.addRoundedRect(2, 2, size - 4, size - 4, 16, 16)
+ painter.fillPath(path, gradient)
+
+ painter.setPen(QColor("#ffffff"))
+ painter.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold))
+ painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "D")
+ painter.end()
+
+ return QIcon(pixmap)
+
# GitHub dialect adds fenced code blocks with language tags, tables, and
# strikethrough on top of CommonMark; NoHTML stops raw HTML in the model's
# output from being interpreted as literal markup in the chat view.
@@ -103,6 +304,48 @@ def _reset_cursor_format(cursor: QTextCursor) -> None:
cursor.insertBlock(QTextBlockFormat(), QTextCharFormat())
+def _insert_message_card(cursor: QTextCursor, accent: str, bg: str) -> QTextCursor:
+ """Insert a flat card with a colored accent stripe for one chat message,
+ and return a cursor positioned inside it, ready for content.
+
+ QTextBrowser's HTML subset has no border-radius, so a rounded chat bubble
+ isn't achievable directly; a two-column table (a thin accent-colored
+ strip beside a tinted message body) stands in for one. Each message gets
+ its own table/cell, so - unlike the previous shared-cursor approach -
+ there's no risk of one message's formatting bleeding into the next.
+ """
+ table_format = QTextTableFormat()
+ table_format.setCellPadding(0)
+ table_format.setCellSpacing(0)
+ table_format.setBorder(0)
+ table_format.setWidth(QTextLength(QTextLength.Type.PercentageLength, 100))
+ table_format.setColumnWidthConstraints(
+ [
+ QTextLength(QTextLength.Type.FixedLength, 3),
+ QTextLength(QTextLength.Type.PercentageLength, 100),
+ ]
+ )
+ table_format.setTopMargin(2)
+ table_format.setBottomMargin(10)
+
+ table = cursor.insertTable(1, 2, table_format)
+
+ strip_format = QTextTableCellFormat()
+ strip_format.setBackground(QColor(accent))
+ table.cellAt(0, 0).setFormat(strip_format)
+
+ body_format = QTextTableCellFormat()
+ body_format.setBackground(QColor(bg))
+ body_format.setTopPadding(10)
+ body_format.setBottomPadding(10)
+ body_format.setLeftPadding(14)
+ body_format.setRightPadding(14)
+ body_cell = table.cellAt(0, 1)
+ body_cell.setFormat(body_format)
+
+ return body_cell.firstCursorPosition()
+
+
class ModelLoaderThread(QThread):
loaded = pyqtSignal(object)
failed = pyqtSignal(str)
@@ -155,30 +398,66 @@ class ChatWindow(QMainWindow):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
+ layout.setContentsMargins(20, 16, 20, 16)
+ layout.setSpacing(14)
+
+ header = QWidget()
+ header.setObjectName("header")
+ header_layout = QHBoxLayout(header)
+ header_layout.setContentsMargins(4, 0, 4, 12)
+ header_layout.setSpacing(12)
+
+ title_box = QVBoxLayout()
+ title_box.setSpacing(0)
+ title_label = QLabel("Dev Assist")
+ title_label.setObjectName("titleLabel")
+ subtitle_label = QLabel("Local LLM · tool-augmented")
+ subtitle_label.setObjectName("subtitleLabel")
+ title_box.addWidget(title_label)
+ title_box.addWidget(subtitle_label)
+ header_layout.addLayout(title_box)
+ header_layout.addStretch(1)
+
+ self._n_ctx = 0
+ self.context_status_label = QLabel("Context: --")
+ self.context_status_label.setObjectName("badge")
+ header_layout.addWidget(self.context_status_label)
+
+ self.gpu_status_label = QLabel("GPU: --")
+ self.gpu_status_label.setObjectName("badge")
+ header_layout.addWidget(self.gpu_status_label)
+
+ layout.addWidget(header)
self.chat_view = QTextBrowser()
+ self.chat_view.setObjectName("chatView")
self.chat_view.setOpenExternalLinks(True)
- self.chat_view.setFont(QFont("Segoe UI", 10))
+ self.chat_view.setFont(QFont("Segoe UI Variable Text", 10))
layout.addWidget(self.chat_view)
input_row = QHBoxLayout()
+ input_row.setSpacing(10)
+
self.input_box = QLineEdit()
+ self.input_box.setObjectName("messageInput")
self.input_box.setPlaceholderText("Ask a development question...")
+ self.input_box.setMinimumHeight(40)
+ input_palette = self.input_box.palette()
+ input_palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(TEXT_SECONDARY))
+ self.input_box.setPalette(input_palette)
self.input_box.returnPressed.connect(self._on_send)
input_row.addWidget(self.input_box)
- self.send_button = QPushButton("Send")
+ self.send_button = QPushButton("Send →")
+ self.send_button.setObjectName("sendButton")
+ self.send_button.setMinimumHeight(40)
+ self.send_button.setMinimumWidth(96)
+ self.send_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.send_button.clicked.connect(self._on_send)
input_row.addWidget(self.send_button)
layout.addLayout(input_row)
- self._n_ctx = 0
- self.context_status_label = QLabel("Context: --")
- self.statusBar().addPermanentWidget(self.context_status_label)
-
- self.gpu_status_label = QLabel()
- self.statusBar().addPermanentWidget(self.gpu_status_label)
self._init_gpu_monitor()
self.statusBar().showMessage("Loading model...")
@@ -281,66 +560,69 @@ class ChatWindow(QMainWindow):
self._set_input_enabled(True)
def _append_user(self, text: str) -> None:
- self._append_block("You", text, "#2b6cb0")
+ accent, bg = ROLE_STYLES["user"]
+ self._append_block("You", text, accent, bg)
def _append_assistant(self, text: str) -> None:
- self._append_markdown_block("Assistant", text, "#2f855a")
+ accent, bg = ROLE_STYLES["assistant"]
+ self._append_markdown_block("Assistant", text, accent, bg)
def _append_system(self, text: str) -> None:
- self._append_block("System", text, "#718096")
+ accent, bg = ROLE_STYLES["system"]
+ self._append_block("System", text, accent, bg)
- def _append_block(self, label: str, text: str, color: str) -> None:
+ def _append_block(self, label: str, text: str, accent: str, bg: str) -> None:
escaped = (
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\n", "
")
)
- self.chat_view.append(f'
{label}:
{escaped}