aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 17:48:21 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 17:48:21 +0200
commit8cb5cc303f1c83d2c63d5cb972a7faee294e7c23 (patch)
treecc8e5d23a680f551a9e8b60ac0be0fbc87fd3ea5
parentd2459356d648825932137e9c7c5c018233c565a1 (diff)
downloadllamachat-8cb5cc303f1c83d2c63d5cb972a7faee294e7c23.tar.gz
llamachat-8cb5cc303f1c83d2c63d5cb972a7faee294e7c23.zip
feat: move model and prompt pickers to the bottom bar, add button icons
-rw-r--r--CHANGELOG.md8
-rw-r--r--llamachat/ui.py186
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 `<pre>` 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)