summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-31 11:03:51 +0200
committerDanilo M. <danix@danix.xyz>2026-07-31 11:03:51 +0200
commit9182c84b8ac9d905a9d553b95155a993338568b8 (patch)
treec1a56b7b6a49099b25e7a2dee4e0f908d5a986c5
parent325a6a7c12832d8b9fb9b67567e8a1c496dc1330 (diff)
downloadllamachat-9182c84b8ac9d905a9d553b95155a993338568b8.tar.gz
llamachat-9182c84b8ac9d905a9d553b95155a993338568b8.zip
feat: add context meter and file-based system prompts
Two additions to the chat window. A context meter in the top bar shows how much of the active model's window the next request will occupy: system prompt, prior turns, attachments and the current draft. The router does not report a usable context size in router mode (/props returns n_ctx 0), so the limit comes from ctx-size in presets.ini. Streaming requests now ask for usage statistics, which makes the figure exact after each reply at no extra round trip; before that it is an estimate marked with a leading ~. The estimate reads low on models with a reasoning budget, since thinking tokens cannot be known before the reply arrives. The tooltip says so. System prompts are markdown files in the prompts/ directory beside the config, one per file, so they can be edited in an editor and kept in version control. default.md is global, any other file is a named preset that replaces it rather than adding to it, and a conversation may instead carry one-off text or opt out entirely. The choice is stored per session so reopening a chat restores the prompt it was built with. Prompt names are reduced to safe filenames, so a name like ../../etc/passwd cannot write outside the prompts directory, and a name that reduces to nothing is rejected rather than creating a dotfile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md22
-rw-r--r--README.md73
-rw-r--r--llamachat/backend.py41
-rw-r--r--llamachat/config.py16
-rw-r--r--llamachat/db.py64
-rw-r--r--llamachat/prompts.py142
-rw-r--r--llamachat/ui.py399
-rwxr-xr-xtest_llamachat.py175
8 files changed, 900 insertions, 32 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7435e3..1b71e76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Context meter in the top bar showing how much of the active model's
+ context window the next request will use. It estimates while typing and
+ switches to the server's exact token counts after each reply, turning
+ amber at 75% and red at 90%. The limit comes from `ctx-size` in
+ `presets.ini`, since the router does not report it.
+- System prompts stored as markdown files in `~/.config/llamachat/prompts/`.
+ A global `default.md` applies to new conversations, named presets replace
+ it, and any conversation can take a one-off custom prompt or none at all.
+ A dialog behind the picker adds, edits and deletes them; the files stay
+ editable outside the app.
+- `default_prompt` in `config.toml` chooses what new conversations start
+ with.
+- Streaming requests now ask for usage statistics, which is what makes the
+ meter exact without an extra round trip.
+
+### Changed
+
+- Sessions record the system prompt they were built with, so reopening a
+ conversation restores it rather than applying whatever is selected now.
+
## [0.1.0] - 2026-07-31
First working version.
diff --git a/README.md b/README.md
index 7c05beb..fdcca99 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,14 @@ persistent process and toggles like a scratchpad from a Hyprland keybind.
thinking separately from its answer. It shows as a one-line `▸ thinking`
summary above the reply, and clicking that expands it. Each reply collapses
independently and the state is remembered per bubble.
+- **Context meter** in the top bar showing how much of the model's context
+ window the next request will use. It estimates while you type and becomes
+ exact after each reply, turning amber at 75% and red at 90%.
+- **System prompts** kept as markdown files in `~/.config/llamachat/prompts/`.
+ A global `default.md` applies to new chats, named presets replace it, and
+ any conversation can take a one-off custom prompt or none at all. The
+ choice is stored per session, so reopening an old chat restores the prompt
+ it was built with.
- **Tray icon** for show/hide/quit, hosted by waybar's tray module.
Not in this version: RAG or embedding search over history, multi-user
@@ -118,6 +126,10 @@ db = ""
default_model = ""
request_timeout = 300
+# System prompt for new conversations: a preset name from prompts/,
+# "none" for no system prompt, or empty for the global default.md.
+default_prompt = ""
+
attach_ctx_fraction = 0.5
chars_per_token = 3.5
```
@@ -252,7 +264,8 @@ there is no second thread.
### Database schema
```sql
-sessions (id, mode, title, model, created_at, updated_at)
+sessions (id, mode, title, model, prompt_name, prompt_custom,
+ created_at, updated_at)
messages (id, session_id, role, content, reasoning, created_at)
attachments (id, message_id, path, kind, mime, size, sha256, thumb, truncated)
messages_fts -- FTS5 external-content table over messages.content
@@ -265,8 +278,16 @@ finishes.
`reasoning` holds the model's thinking, kept in its own column for two
reasons: the FTS index covers `content` only, so thinking text cannot bury
real search hits, and only `content` is replayed when continuing a
-conversation, which is what these APIs expect. Databases created before this
-column existed gain it automatically on open.
+conversation, which is what these APIs expect.
+
+`prompt_name` records which system prompt a conversation was built with, and
+`prompt_custom` holds the text when that prompt is a one-off rather than a
+file. Storing the name rather than the resolved text means editing a preset
+affects the conversations that use it, which is usually what you want; a
+conversation that needed frozen wording should use a custom prompt.
+
+Databases created before any of these columns existed gain them
+automatically on open.
Attachment *content* lives inline in `messages.content`, since that is what
was actually sent to the model. The `attachments` table records provenance:
@@ -278,6 +299,52 @@ is gone. Full image bytes are not stored.
Search input is tokenised and quoted before it reaches FTS5, so punctuation
that would otherwise be read as query syntax cannot cause an error.
+### System prompts
+
+Prompts are markdown files in `~/.config/llamachat/prompts/`, one per file,
+created on first run:
+
+```
+prompts/
+ default.md the global prompt, used unless something overrides it
+ coding.md a named preset
+ terse.md another
+```
+
+They are plain files on purpose, so they can be edited in an editor, diffed,
+and kept in version control. The `…` button beside the picker opens a dialog
+that does the same thing from inside the app.
+
+The picker offers every file plus two extras. A named preset **replaces** the
+global prompt rather than being appended to it. `custom for this chat…` takes
+one-off text stored with that conversation, and `no system prompt` sends none
+at all.
+
+`default_prompt` in `config.toml` chooses what new conversations start with:
+a preset name, `"none"`, or empty for `default.md`.
+
+The choice is recorded per session, so reopening a chat restores the prompt
+it was built with rather than silently applying whatever is selected now.
+
+### The context meter
+
+The bar in the top bar shows how much of the active model's context window
+the next request will occupy: system prompt, prior turns, attachments, and
+whatever is currently in the input box.
+
+While you type it is an estimate derived from `chars_per_token`, shown with a
+leading `~`. When a reply finishes, the server reports the real token counts
+in the stream and the meter switches to those, dropping the `~`.
+
+Expect the estimate to read low on models with a reasoning budget. Thinking
+tokens count against the context but cannot be known before the reply
+arrives, so a model that thinks for 2000 tokens will jump well past the
+estimate once it answers. The exact figure after each turn is the one to
+trust.
+
+The limit comes from `ctx-size` in `presets.ini`. Models the router offers
+but that file does not describe show no limit.
+
### Markdown rendering
Replies are parsed by `QTextDocument.setMarkdown()`, so there is no markdown
diff --git a/llamachat/backend.py b/llamachat/backend.py
index 89cb044..9e0556f 100644
--- a/llamachat/backend.py
+++ b/llamachat/backend.py
@@ -184,7 +184,15 @@ class Client:
so the first chunk can take several seconds. That wait happens
inside the initial `stream()` call.
"""
- body = {"model": model, "messages": messages, "stream": True}
+ body = {
+ "model": model,
+ "messages": messages,
+ "stream": True,
+ # The final chunk then carries exact prompt/completion counts,
+ # which is what makes the context meter truthful rather than an
+ # estimate, at no extra request.
+ "stream_options": {"include_usage": True},
+ }
try:
with httpx.stream(
"POST",
@@ -208,6 +216,32 @@ class Client:
raise BackendError(f"Request failed: {exc}")
+def estimate_tokens(messages: list[dict], chars_per_token: float) -> int:
+ """Rough token count for a request that has not been sent yet.
+
+ Used for the live meter while typing. Once a reply completes, the exact
+ count from the usage block replaces this. Images are counted as a flat
+ allowance rather than by dimension, which is enough to show that a
+ picture is expensive without pretending to know the tiling.
+ """
+ chars = 0
+ images = 0
+ for message in messages:
+ chars += len(str(message.get("role", "")))
+ content = message.get("content")
+ if isinstance(content, str):
+ chars += len(content)
+ continue
+ for part in content or []:
+ if part.get("type") == "text":
+ chars += len(part.get("text", ""))
+ else:
+ images += 1
+ # A few tokens per message go to the chat template's own markup.
+ overhead = 4 * len(messages)
+ return int(chars / max(chars_per_token, 1.0)) + overhead + images * 600
+
+
def _parse_sse_line(line: str) -> tuple[str, str] | None:
"""Decode one SSE line.
@@ -223,6 +257,11 @@ def _parse_sse_line(line: str) -> tuple[str, str] | None:
obj = json.loads(payload)
except ValueError:
return None
+ # The usage chunk arrives with an empty choices list, so check it first.
+ usage = obj.get("usage")
+ if usage and usage.get("prompt_tokens") is not None:
+ return ("usage", json.dumps(usage))
+
choices = obj.get("choices") or []
if not choices:
return None
diff --git a/llamachat/config.py b/llamachat/config.py
index fac72d5..5c5af14 100644
--- a/llamachat/config.py
+++ b/llamachat/config.py
@@ -34,8 +34,14 @@ DEFAULTS = {
"attach_ctx_fraction": 0.5,
# Rough chars-per-token used to turn a ctx-size into a char budget.
"chars_per_token": 3.5,
+ # Named prompt selected for new conversations. Empty means the global
+ # default.md, and "none" means no system prompt at all.
+ "default_prompt": "",
}
+# The global prompt lives here; every other .md beside it is a named preset.
+GLOBAL_PROMPT = "default"
+
@dataclass
class Preset:
@@ -58,6 +64,8 @@ class Config:
request_timeout: int
attach_ctx_fraction: float
chars_per_token: float
+ default_prompt: str
+ prompts_dir: Path
def _runtime_dir() -> Path:
@@ -94,6 +102,8 @@ def load(path: Path = CONFIG_PATH) -> Config:
request_timeout=int(values["request_timeout"]),
attach_ctx_fraction=float(values["attach_ctx_fraction"]),
chars_per_token=float(values["chars_per_token"]),
+ default_prompt=str(values["default_prompt"]),
+ prompts_dir=path.parent / "prompts",
)
@@ -116,6 +126,12 @@ def write_default(path: Path = CONFIG_PATH) -> Path:
'# Model selected at startup. Empty picks the first available.\n'
'default_model = ""\n'
'\n'
+ '# System prompt for new conversations. Prompts are markdown files\n'
+ '# in the prompts/ directory beside this file; default.md is the\n'
+ '# global one. Name a preset to use it instead, or "none" for no\n'
+ '# system prompt. Empty means default.md.\n'
+ 'default_prompt = ""\n'
+ '\n'
'# Seconds before a generation request is abandoned.\n'
f'request_timeout = {DEFAULTS["request_timeout"]}\n'
'\n'
diff --git a/llamachat/db.py b/llamachat/db.py
index d746a0e..c13f03c 100644
--- a/llamachat/db.py
+++ b/llamachat/db.py
@@ -22,12 +22,14 @@ PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS sessions (
- id INTEGER PRIMARY KEY,
- mode TEXT NOT NULL,
- title TEXT,
- model TEXT,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
+ id INTEGER PRIMARY KEY,
+ mode TEXT NOT NULL,
+ title TEXT,
+ model TEXT,
+ prompt_name TEXT NOT NULL DEFAULT '',
+ prompt_custom TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
@@ -98,31 +100,57 @@ class History:
def _migrate(self) -> None:
"""Add columns introduced after a database was first created."""
- columns = {
- row["name"]
- for row in self.conn.execute("PRAGMA table_info(messages)")
+ added = {
+ "messages": {"reasoning": "TEXT NOT NULL DEFAULT ''"},
+ "sessions": {
+ "prompt_name": "TEXT NOT NULL DEFAULT ''",
+ "prompt_custom": "TEXT NOT NULL DEFAULT ''",
+ },
}
- if "reasoning" not in columns:
- self.conn.execute(
- "ALTER TABLE messages ADD COLUMN reasoning TEXT NOT NULL"
- " DEFAULT ''"
- )
+ for table, wanted in added.items():
+ existing = {
+ row["name"]
+ for row in self.conn.execute(f"PRAGMA table_info({table})")
+ }
+ for column, spec in wanted.items():
+ if column not in existing:
+ self.conn.execute(
+ f"ALTER TABLE {table} ADD COLUMN {column} {spec}"
+ )
def close(self) -> None:
self.conn.close()
# -- sessions ---------------------------------------------------------
- def create_session(self, mode: str, model: str, title: str = "") -> int:
+ def create_session(
+ self,
+ mode: str,
+ model: str,
+ title: str = "",
+ prompt_name: str = "",
+ prompt_custom: str = "",
+ ) -> int:
now = _now()
cur = self.conn.execute(
- "INSERT INTO sessions (mode, title, model, created_at, updated_at)"
- " VALUES (?, ?, ?, ?, ?)",
- (mode, title, model, now, now),
+ "INSERT INTO sessions"
+ " (mode, title, model, prompt_name, prompt_custom,"
+ " created_at, updated_at)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (mode, title, model, prompt_name, prompt_custom, now, now),
)
self.conn.commit()
return int(cur.lastrowid)
+ def set_prompt(self, session_id: int, name: str, custom: str = "") -> None:
+ """Record which system prompt a conversation was built with."""
+ self.conn.execute(
+ "UPDATE sessions SET prompt_name = ?, prompt_custom = ?"
+ " WHERE id = ?",
+ (name, custom, session_id),
+ )
+ self.conn.commit()
+
def touch_session(self, session_id: int, model: str | None = None) -> None:
if model:
self.conn.execute(
diff --git a/llamachat/prompts.py b/llamachat/prompts.py
new file mode 100644
index 0000000..df56bca
--- /dev/null
+++ b/llamachat/prompts.py
@@ -0,0 +1,142 @@
+# 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.
+"""System prompts kept as plain files.
+
+One markdown file per prompt in the prompts directory. `default.md` is the
+global prompt; every other file is a named preset that *replaces* it rather
+than adding to it. A conversation may also carry its own text, or opt out of
+a system prompt entirely.
+
+Files rather than a table because these are things you want to edit in an
+editor, diff, and keep in version control.
+"""
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from .config import GLOBAL_PROMPT
+
+# Sentinels stored on a session in place of a prompt name: NONE sends no
+# system prompt at all, CUSTOM means the session carries its own text.
+NONE = "none"
+CUSTOM = "custom"
+
+DEFAULT_TEXT = (
+ "You are a helpful assistant running locally. Be concise and direct.\n"
+)
+
+# Conservative: these names become filenames.
+_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]")
+
+
+@dataclass
+class Prompt:
+ name: str
+ text: str
+ path: Path
+
+ @property
+ def is_global(self) -> bool:
+ return self.name == GLOBAL_PROMPT
+
+
+def safe_name(name: str) -> str:
+ """Reduce a display name to something usable as a filename.
+
+ Path separators and dots are stripped, so a name like "../../etc/passwd"
+ cannot escape the prompts directory. A name that reduces to nothing
+ yields "" and callers must treat that as invalid.
+ """
+ cleaned = _SAFE_NAME.sub("-", name.strip()).strip("-.")
+ return cleaned[:64]
+
+
+class PromptStore:
+ """The prompts directory, read and written as individual files."""
+
+ def __init__(self, directory: Path):
+ self.dir = directory
+
+ def ensure_default(self) -> None:
+ """Create the directory and a starter global prompt if absent."""
+ self.dir.mkdir(parents=True, exist_ok=True)
+ target = self.path_for(GLOBAL_PROMPT)
+ if not target.exists():
+ target.write_text(DEFAULT_TEXT, encoding="utf-8")
+
+ def path_for(self, name: str) -> Path:
+ cleaned = safe_name(name)
+ if not cleaned:
+ raise ValueError(f"unusable prompt name: {name!r}")
+ return self.dir / f"{cleaned}.md"
+
+ def names(self) -> list[str]:
+ """Prompt names, global first, then presets alphabetically."""
+ if not self.dir.is_dir():
+ return []
+ found = sorted(
+ p.stem for p in self.dir.glob("*.md") if p.is_file()
+ )
+ if GLOBAL_PROMPT in found:
+ found.remove(GLOBAL_PROMPT)
+ return [GLOBAL_PROMPT] + found
+ return found
+
+ def load(self, name: str) -> Prompt | None:
+ try:
+ path = self.path_for(name)
+ text = path.read_text(encoding="utf-8")
+ except (OSError, ValueError):
+ return None
+ return Prompt(name=safe_name(name), text=text, path=path)
+
+ def save(self, name: str, text: str) -> Prompt:
+ self.dir.mkdir(parents=True, exist_ok=True)
+ path = self.path_for(name)
+ path.write_text(text, encoding="utf-8")
+ return Prompt(name=safe_name(name), text=text, path=path)
+
+ def delete(self, name: str) -> bool:
+ """Remove a preset. The global prompt is never deleted."""
+ if safe_name(name) == GLOBAL_PROMPT:
+ return False
+ try:
+ path = self.path_for(name)
+ except ValueError:
+ return False
+ if not path.exists():
+ return False
+ path.unlink()
+ return True
+
+ def resolve(self, choice: str, custom: str = "") -> str:
+ """The system prompt text a conversation should actually send.
+
+ `choice` is a prompt name, the NONE sentinel, or empty for the
+ global default. A named preset replaces the global prompt rather
+ than being appended to it.
+ """
+ if choice == NONE:
+ return ""
+ if choice == "":
+ choice = GLOBAL_PROMPT
+ if choice == CUSTOM:
+ return custom.strip()
+ prompt = self.load(choice)
+ if prompt is None:
+ # A preset that was deleted behind our back falls back to the
+ # global prompt rather than silently sending nothing.
+ prompt = self.load(GLOBAL_PROMPT)
+ return prompt.text.strip() if prompt else ""
diff --git a/llamachat/ui.py b/llamachat/ui.py
index d8ec10e..f398510 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -14,22 +14,24 @@
"""The chat window."""
import html
+import json
import re
from pathlib import Path
-from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot
+from PySide6.QtCore import QObject, QRect, Qt, QThread, Signal, Slot
from PySide6.QtGui import (
- QAction, QDesktopServices, QKeySequence, QTextDocument,
+ QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument,
)
from PySide6.QtWidgets import (
- QButtonGroup, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
- QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox,
- QPlainTextEdit, QPushButton, QRadioButton, QSplitter, QTextBrowser,
- QVBoxLayout, QWidget,
+ QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
+ QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
+ QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
+ QPushButton, QRadioButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget,
)
-from . import backend
+from . import backend, prompts
from .backend import Attachment, BackendError
+from .config import GLOBAL_PROMPT
MODE_ONESHOT = "oneshot"
MODE_CHAT = "chat"
@@ -39,11 +41,98 @@ MODE_CHAT = "chat"
REASONING_SCHEME = "x-llamachat-reasoning:"
+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)
+
+
class StreamWorker(QObject):
"""Runs one streaming request off the GUI thread."""
chunk = Signal(str)
reasoning = Signal(str)
+ usage = Signal(int, int)
finished = Signal()
failed = Signal(str)
@@ -65,6 +154,12 @@ class StreamWorker(QObject):
break
if kind == "reasoning":
self.reasoning.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:
@@ -76,6 +171,137 @@ class StreamWorker(QObject):
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."""
@@ -85,6 +311,14 @@ class ChatWindow(QMainWindow):
self.history = history
self.client = client
self.presets = presets
+ 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.session_id: int | None = None
self.mode = MODE_ONESHOT
@@ -105,7 +339,9 @@ class ChatWindow(QMainWindow):
self.setAcceptDrops(True)
self._build_ui()
self.refresh_models()
+ self.refresh_prompts()
self.refresh_history()
+ self.update_meter()
# -- construction -----------------------------------------------------
@@ -138,7 +374,23 @@ 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)
+
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search history…")
self.search_box.setClearButtonEnabled(True)
@@ -185,6 +437,7 @@ class ChatWindow(QMainWindow):
"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()
@@ -286,6 +539,63 @@ class ChatWindow(QMainWindow):
def _on_model_changed(self, _text: str) -> None:
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 ----------------------------------------------------
@@ -303,6 +613,7 @@ class ChatWindow(QMainWindow):
self.assistant_message_id = None
self.assistant_buffer = ""
self.reasoning_buffer = ""
+ self.exact_tokens = 0
self.bubbles.clear()
self.expanded.clear()
self.clear_attachments()
@@ -312,6 +623,8 @@ class ChatWindow(QMainWindow):
self.send_button.setEnabled(True)
self.history_list.clearSelection()
self.hide_status()
+ self.select_prompt(self.cfg.default_prompt)
+ self.update_meter()
# -- attachments ------------------------------------------------------
@@ -384,6 +697,7 @@ class ChatWindow(QMainWindow):
if not self.attachments:
self.attach_label.hide()
self.clear_attach_button.hide()
+ self.update_meter()
return
names = []
for att in self.attachments:
@@ -393,6 +707,7 @@ class ChatWindow(QMainWindow):
self.attach_label.setText("Attached: " + ", ".join(names))
self.attach_label.show()
self.clear_attach_button.show()
+ self.update_meter()
# -- drag and drop ----------------------------------------------------
@@ -437,7 +752,11 @@ class ChatWindow(QMainWindow):
if self.session_id is None:
title = (text or self.attachments[0].path.name)[:60]
self.session_id = self.history.create_session(
- self.mode, model, title
+ self.mode,
+ model,
+ title,
+ prompt_name=self.prompt_name,
+ prompt_custom=self.prompt_custom,
)
content = backend.build_user_content(text, self.attachments)
@@ -461,7 +780,8 @@ class ChatWindow(QMainWindow):
if self.mode == MODE_CHAT:
messages = self._chat_context(content)
else:
- messages = [{"role": "user", "content": content}]
+ messages = self._system_messages()
+ messages.append({"role": "user", "content": content})
self.clear_attachments()
self.refresh_history()
@@ -469,13 +789,54 @@ class ChatWindow(QMainWindow):
def _chat_context(self, latest_content) -> list[dict]:
"""Prior turns plus the new message, for multi-turn mode."""
- messages = []
+ 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]:
+ text = self.system_prompt_text()
+ 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
+ preset = self.current_preset()
+ limit = preset.ctx_size if preset else 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 _start_stream(self, model: str, messages: list[dict]) -> None:
self.assistant_buffer = ""
self.reasoning_buffer = ""
@@ -493,6 +854,7 @@ class ChatWindow(QMainWindow):
self.thread.started.connect(self.worker.run)
self.worker.chunk.connect(self._on_chunk)
self.worker.reasoning.connect(self._on_reasoning)
+ 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()
@@ -504,6 +866,15 @@ class ChatWindow(QMainWindow):
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."""
+ preset = self.current_preset()
+ limit = preset.ctx_size if preset else 0
+ # What the next turn starts from is everything sent plus the reply.
+ self.exact_tokens = total_tokens or prompt_tokens
+ self.meter.set_usage(self.exact_tokens, limit, exact=True)
+
@Slot(str)
def _on_reasoning(self, piece: str) -> None:
if not self.reasoning_buffer:
@@ -687,8 +1058,14 @@ class ChatWindow(QMainWindow):
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)
@@ -718,6 +1095,8 @@ class ChatWindow(QMainWindow):
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."
diff --git a/test_llamachat.py b/test_llamachat.py
index 4a113e0..7707b7b 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -14,6 +14,7 @@
# GNU General Public License for more details.
"""Self-checks for the non-GUI logic. Run: ./test_llamachat.py"""
+import json
import os
import sys
import tempfile
@@ -409,6 +410,175 @@ def test_system_qt_theme_guard():
print("ok system qt theme guard")
+def test_prompt_store():
+ """Prompts are files; a preset replaces the global one."""
+ from llamachat import prompts
+
+ with tempfile.TemporaryDirectory() as tmp:
+ store = prompts.PromptStore(Path(tmp) / "prompts")
+ assert store.names() == []
+
+ store.ensure_default()
+ assert store.names() == ["default"]
+ assert store.path_for("default").exists()
+
+ # ensure_default must not clobber an edited global prompt.
+ store.save("default", "edited global")
+ store.ensure_default()
+ assert store.load("default").text == "edited global"
+
+ store.save("coding", "you write code")
+ store.save("terse", "be brief")
+ # Global first, then presets alphabetically.
+ assert store.names() == ["default", "coding", "terse"]
+
+ # A preset replaces the global prompt rather than adding to it.
+ assert store.resolve("coding") == "you write code"
+ assert store.resolve("") == "edited global"
+ assert store.resolve("default") == "edited global"
+
+ # Sentinels.
+ assert store.resolve(prompts.NONE) == ""
+ assert store.resolve(prompts.CUSTOM, "one off") == "one off"
+
+ # A preset deleted behind our back falls back rather than sending
+ # nothing, which would silently change the model's behaviour.
+ assert store.resolve("missing") == "edited global"
+
+ assert store.delete("coding") is True
+ assert store.names() == ["default", "terse"]
+ # The global prompt is the fallback for everything, so it stays.
+ assert store.delete("default") is False
+ assert "default" in store.names()
+
+ # Names become filenames, so path separators must not escape.
+ assert prompts.safe_name("../../etc/passwd") == "etc-passwd"
+ assert prompts.safe_name("/etc/passwd") == "etc-passwd"
+ assert prompts.safe_name("my prompt!") == "my-prompt"
+ for hostile in ("../escape", "a/b", "/tmp/x"):
+ saved = store.save(hostile, "x")
+ assert saved.path.parent == store.dir, saved.path
+ assert saved.path.resolve().parent == store.dir.resolve()
+
+ # A name that reduces to nothing is rejected rather than writing
+ # a dotfile called ".md".
+ for empty in ("..", "", "....", "///"):
+ assert prompts.safe_name(empty) == ""
+ assert store.load(empty) is None
+ assert store.delete(empty) is False
+ try:
+ store.path_for(empty)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError(f"{empty!r} should be rejected")
+ print("ok prompt store")
+
+
+def test_token_estimate():
+ """The meter's pre-send estimate tracks message size."""
+ small = [{"role": "user", "content": "hi"}]
+ large = [{"role": "user", "content": "word " * 1000}]
+
+ assert backend.estimate_tokens([], 3.5) == 0
+ assert backend.estimate_tokens(small, 3.5) < 20
+ assert backend.estimate_tokens(large, 3.5) > 1000
+
+ # More history means a bigger prompt.
+ grown = large + [{"role": "assistant", "content": "reply " * 500}]
+ assert backend.estimate_tokens(grown, 3.5) > backend.estimate_tokens(
+ large, 3.5
+ )
+
+ # An image costs far more than its text part suggests.
+ with_image = [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "what is this"},
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
+ ],
+ }
+ ]
+ assert backend.estimate_tokens(with_image, 3.5) > 500
+
+ # A silly ratio must not divide by zero.
+ assert backend.estimate_tokens(small, 0) > 0
+ print("ok token estimate")
+
+
+def test_usage_parsing():
+ """The final stream chunk carries the exact prompt token count."""
+ line = (
+ 'data: {"choices":[],"usage":{"prompt_tokens":1234,'
+ '"completion_tokens":56,"total_tokens":1290}}'
+ )
+ kind, payload = backend._parse_sse_line(line)
+ assert kind == "usage"
+ stats = json.loads(payload)
+ assert stats["prompt_tokens"] == 1234
+ assert stats["total_tokens"] == 1290
+
+ # A usage-less chunk with empty choices is not mistaken for one.
+ assert backend._parse_sse_line('data: {"choices":[]}') is None
+ print("ok usage parsing")
+
+
+def test_session_prompt_storage():
+ """A conversation remembers the prompt it was built with."""
+ from llamachat import prompts
+
+ with tempfile.TemporaryDirectory() as tmp:
+ history = db.History(Path(tmp) / "p.db")
+
+ sid = history.create_session(
+ "chat", "m", "t", prompt_name="coding", prompt_custom=""
+ )
+ row = history.get_session(sid)
+ assert row["prompt_name"] == "coding"
+ assert row["prompt_custom"] == ""
+
+ history.set_prompt(sid, prompts.CUSTOM, "just this once")
+ row = history.get_session(sid)
+ assert row["prompt_name"] == prompts.CUSTOM
+ assert row["prompt_custom"] == "just this once"
+
+ # Defaults keep older call sites working.
+ plain = history.create_session("oneshot", "m", "t")
+ assert history.get_session(plain)["prompt_name"] == ""
+ history.close()
+ print("ok session prompt storage")
+
+
+def test_prompt_column_migration():
+ """A database predating the prompt columns must still open."""
+ import sqlite3
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "old.db"
+ conn = sqlite3.connect(path)
+ conn.executescript(
+ "CREATE TABLE sessions (id INTEGER PRIMARY KEY, mode TEXT,"
+ " title TEXT, model TEXT, created_at INTEGER, updated_at INTEGER);"
+ "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id INTEGER,"
+ " role TEXT NOT NULL, content TEXT NOT NULL,"
+ " created_at INTEGER NOT NULL);"
+ "INSERT INTO sessions VALUES (1,'chat','old','m',0,0);"
+ )
+ conn.commit()
+ conn.close()
+
+ history = db.History(path)
+ row = history.get_session(1)
+ assert row["title"] == "old"
+ assert row["prompt_name"] == ""
+ assert row["prompt_custom"] == ""
+ history.set_prompt(1, "coding")
+ assert history.get_session(1)["prompt_name"] == "coding"
+ history.close()
+ print("ok prompt column migration")
+
+
def test_version_matches_changelog():
"""The package version must be the newest release in the changelog."""
import re
@@ -506,6 +676,11 @@ if __name__ == "__main__":
test_reasoning_storage()
test_migration_adds_reasoning()
test_user_content()
+ test_prompt_store()
+ test_token_estimate()
+ test_usage_parsing()
+ test_session_prompt_storage()
+ test_prompt_column_migration()
test_markdown_rendering()
test_system_qt_theme_guard()
test_version_matches_changelog()