From 8cb5cc303f1c83d2c63d5cb972a7faee294e7c23 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 25 Aug 2026 17:48:21 +0200 Subject: feat: move model and prompt pickers to the bottom bar, add button icons --- CHANGELOG.md | 8 +++ llamachat/ui.py | 186 +++++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 145 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc13970..a79898b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,9 +69,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 moment once used. The text comes from the source markdown, since Qt emits one `
` per line and the block cannot be recovered from the rendered
   HTML.
+- Icons on the chat buttons (New, Attach, Clear files, Stop, Send), drawn
+  at runtime so there is still no icon file to install. Send is now a square
+  button beside the entry box with its label under the icon, and New/Attach
+  sit at the right end of the picker row.
 
 ### Fixed
 
+- The model and prompt pickers lived in the top bar, so a narrow window
+  pushed them into the mode toggle and the context meter. They now live in
+  the bottom bar, and the top bar keeps only the mode toggle and the
+  read-outs.
 - A long line in a code block widened the whole transcript and left it
   scrolling sideways. Qt's markdown stylesheet wraps `p` and `li` but not
   `pre`, and it is dropped when only the body fragment is kept, so the block
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 210c515..c22b3db 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -22,10 +22,11 @@ import re
 from pathlib import Path
 
 from PySide6.QtCore import (
-    QObject, QRect, QSettings, Qt, QThread, QTimer, Signal, Slot,
+    QObject, QPointF, QRect, QRectF, QSettings, Qt, QThread, QTimer, Signal, Slot,
 )
 from PySide6.QtGui import (
-    QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument,
+    QAction, QColor, QDesktopServices, QIcon, QKeySequence, QPainter,
+    QPainterPath, QPen, QPixmap, QPolygonF, QTextDocument,
 )
 from PySide6.QtWidgets import (
     QApplication, QButtonGroup, QComboBox, QDialog, QDialogButtonBox,
@@ -53,6 +54,67 @@ SEARCH_SCHEME = "x-llamachat-search:"
 COPY_SCHEME = "x-llamachat-copy:"
 
 
+# The buttons get their glyphs drawn at runtime, so there is no icon file
+# to install. All the marks share the brand blue, which also makes each
+# button an unambiguous control rather than a bare text label.
+def _button_icon(kind: str) -> QIcon:
+    """A small monochrome glyph for one of the chat buttons."""
+    pixmap = QPixmap(24, 24)
+    pixmap.fill(QColor(0, 0, 0, 0))
+    painter = QPainter(pixmap)
+    painter.setRenderHint(QPainter.Antialiasing)
+    colour = QColor("#3F5EFB")
+    pen = QPen(colour, 2.2)
+    pen.setCapStyle(Qt.RoundCap)
+    pen.setJoinStyle(Qt.RoundJoin)
+    painter.setPen(pen)
+
+    if kind == "new":
+        painter.drawLine(12, 4, 12, 20)
+        painter.drawLine(4, 12, 20, 12)
+    elif kind == "attach":
+        # A paperclip: one stroke spiralling into the classic double loop.
+        path = QPainterPath()
+        path.moveTo(13.5, 16.5)
+        path.lineTo(13.5, 8.0)
+        path.cubicTo(13.5, 5.5, 10.5, 5.5, 10.5, 8.0)
+        path.lineTo(10.5, 15.5)
+        path.cubicTo(10.5, 18.0, 15.0, 18.0, 15.0, 15.0)
+        path.lineTo(15.0, 7.0)
+        path.cubicTo(15.0, 3.5, 7.5, 3.5, 7.5, 7.0)
+        path.lineTo(7.5, 16.5)
+        path.cubicTo(7.5, 20.0, 14.0, 20.0, 14.0, 16.5)
+        painter.drawPath(path)
+    elif kind == "clear":
+        # A bin with a lid, for removing attached files.
+        painter.drawLine(7, 6.5, 17, 6.5)
+        painter.drawLine(10, 4.5, 14, 4.5)
+        path = QPainterPath()
+        path.moveTo(8, 8)
+        path.lineTo(16, 8)
+        path.lineTo(14.5, 19.5)
+        path.lineTo(9.5, 19.5)
+        path.closeSubpath()
+        painter.drawPath(path)
+    elif kind == "stop":
+        painter.setBrush(colour)
+        painter.drawRoundedRect(QRectF(6.5, 6.5, 11, 11), 2, 2)
+    elif kind == "send":
+        painter.setBrush(colour)
+        painter.drawPolygon(
+            QPolygonF(
+                [
+                    QPointF(4.0, 12.0),
+                    QPointF(20.0, 5.0),
+                    QPointF(11.5, 13.0),
+                    QPointF(10.0, 20.0),
+                ]
+            )
+        )
+    painter.end()
+    return QIcon(pixmap)
+
+
 class ContextMeter(QWidget):
     """A bar showing how much of the model's context the next request uses.
 
@@ -501,7 +563,9 @@ class ChatWindow(QMainWindow):
         central = QWidget()
         outer = QVBoxLayout(central)
 
-        # Top bar: model, mode, search.
+        # Top bar: the mode toggle plus the context/cost read-outs. The model
+        # and prompt pickers live in the bottom bar, so a narrow window cannot
+        # push this row's items into one another.
         top = QHBoxLayout()
         self.sidebar_button = QPushButton("☰")
         self.sidebar_button.setCheckable(True)
@@ -511,26 +575,6 @@ class ChatWindow(QMainWindow):
         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")
@@ -542,19 +586,6 @@ class ChatWindow(QMainWindow):
         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)
@@ -619,39 +650,96 @@ class ChatWindow(QMainWindow):
         self.status_row.hide()
         right_layout.addWidget(self.status_row)
 
+        compose = QHBoxLayout()
+        compose.setSpacing(6)
         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)
+        compose.addWidget(self.input, 1)
+
+        # A square send control beside the entry box, like a chat app.
+        self.send_button = QToolButton()
+        self.send_button.setIcon(_button_icon("send"))
+        self.send_button.setText("Send")
+        self.send_button.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
+        self.send_button.setFixedSize(44, 44)
+        self.send_button.setToolTip("Send (Ctrl+Enter)")
+        self.send_button.clicked.connect(self.send)
+        compose.addWidget(self.send_button, 0, Qt.AlignBottom)
+        right_layout.addLayout(compose)
 
+        # Bottom bar: the model and prompt pickers on the left, the
+        # conversation actions on the right, so a narrow window cannot push
+        # the top row together.
         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)
+        buttons.setSpacing(4)
 
-        self.attach_button = QPushButton("Attach…")
-        self.attach_button.clicked.connect(self._on_attach_clicked)
-        buttons.addWidget(self.attach_button)
+        self.model_box = QComboBox()
+        self.model_box.setToolTip("Model to chat with")
+        self.model_box.setMinimumContentsLength(12)
+        self.model_box.setSizeAdjustPolicy(
+            QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon
+        )
+        self.model_box.currentTextChanged.connect(self._on_model_changed)
+        buttons.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)
+        buttons.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)
+        buttons.addWidget(self.reload_button)
+
+        buttons.addSpacing(8)
+        self.prompt_box = QComboBox()
+        self.prompt_box.setToolTip("System prompt for this chat")
+        self.prompt_box.setMinimumContentsLength(10)
+        self.prompt_box.setSizeAdjustPolicy(
+            QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon
+        )
+        self.prompt_box.currentIndexChanged.connect(self._on_prompt_changed)
+        buttons.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)
+        buttons.addWidget(self.prompt_button)
+
+        buttons.addStretch()
         self.clear_attach_button = QPushButton("Clear files")
+        self.clear_attach_button.setIcon(_button_icon("clear"))
         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.setIcon(_button_icon("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)
+        self.new_button = QPushButton("New")
+        self.new_button.setIcon(_button_icon("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.setIcon(_button_icon("attach"))
+        self.attach_button.clicked.connect(self._on_attach_clicked)
+        buttons.addWidget(self.attach_button)
+
         right_layout.addLayout(buttons)
 
         splitter.addWidget(right)
-- 
cgit v1.2.3