summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--CHANGELOG.md73
-rw-r--r--LICENSE338
-rw-r--r--README.md324
-rw-r--r--initial-prompt.md98
-rw-r--r--llamachat.desktop12
-rwxr-xr-xllamachat.py42
-rw-r--r--llamachat/__init__.py15
-rw-r--r--llamachat/__main__.py267
-rw-r--r--llamachat/backend.py237
-rw-r--r--llamachat/config.py167
-rw-r--r--llamachat/db.py254
-rw-r--r--llamachat/ipc.py109
-rw-r--r--llamachat/ui.py902
-rw-r--r--llamachat_venv.py81
-rwxr-xr-xtest_llamachat.py514
16 files changed, 3438 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ac2afd7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a7435e3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,73 @@
+# Changelog
+
+All notable changes to this project are documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [0.1.0] - 2026-07-31
+
+First working version.
+
+### Added
+
+- Persistent background process with a Unix-socket control channel, so a
+ window toggle costs a socket round trip instead of a process start. The
+ control commands (`--toggle`, `--show`, `--hide`, `--ping`, `--quit`) do
+ not import Qt and answer in well under a tenth of a second.
+- Two chat modes. One-shot asks a single question with no context carried
+ over; Chat keeps a resumable multi-turn conversation.
+- Model picker populated at runtime from the router's `/v1/models`, with
+ vision-capable models marked.
+- Streaming replies rendered token by token.
+- Markdown rendering for replies: headings, emphasis, lists, tables, inline
+ code and tinted fenced code blocks, via `QTextDocument` with no markdown
+ dependency. User input is shown literally so attached file contents are
+ never reflowed.
+- Collapsed reasoning. Models with a reasoning budget return their thinking
+ in a separate field; it shows as a one-line summary that expands on click,
+ tracked per reply.
+- File attachment by drag-and-drop or file dialog. Text and code files are
+ inlined into the prompt and truncated to fit the model's context with a
+ warning. Images require a vision model, and dropping one on a text model
+ offers to switch.
+- SQLite history with FTS5 full-text search. Chat sessions reopen and
+ continue with context intact; one-shot entries reopen read-only.
+- Attachment provenance: original path, size, SHA-256, and a thumbnail for
+ images, so a reopened conversation still shows what was sent even if the
+ file has since moved.
+- System tray icon for show/hide/quit.
+- Configuration in `~/.config/llamachat/config.toml`, written with defaults
+ on first run.
+- `presets.ini` parsing for the two things the API does not report: which
+ models have an mmproj file, and each model's context size.
+- Hyprland Lua snippets for autostart, keybind and floating window rules.
+
+### Fixed
+
+- `presets.ini` opens with a bare `version = 1` before any section, which
+ made `configparser` reject the whole file.
+- Sessions sharing a one-second timestamp sorted unpredictably in the
+ history list and in search results.
+- A venv PySide6 bundles almost no Qt plugins, so `QT_QPA_PLATFORMTHEME`
+ found nothing and every window fell back to Fusion, ignoring qt6ct and
+ Kvantum. The system plugin directory is now used when its Qt version
+ matches the bundled one exactly.
+- A reply containing a markdown link to the internal reasoning scheme
+ produced a working toggle, letting model output drive the interface.
+ The scheme is namespaced, forged anchors are rewritten before display,
+ and only `http`, `https` and `mailto` links reach the desktop opener.
+- Venv discovery compared resolved interpreter paths, so a venv created
+ with `--system-site-packages`, whose `bin/python3` symlinks to the system
+ interpreter, was discarded as "already running".
+
+### Security
+
+- Markdown is parsed with the `MarkdownNoHTML` flag, so markup in a reply is
+ displayed rather than interpreted.
+- The control socket is created mode 0600.
+- Search input is tokenised and quoted before reaching FTS5, so punctuation
+ cannot be read as query syntax.
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9efa6fb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,338 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ 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.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Moe Ghoul>, 1 April 1989
+ Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7c05beb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,324 @@
+# llamachat
+
+A small native desktop chat client for a local llama.cpp server running in
+router mode. PySide6, no browser, no Electron. It runs as a single
+persistent process and toggles like a scratchpad from a Hyprland keybind.
+
+## Features
+
+- **Two modes.** One-shot for a quick question with no context carried over,
+ Chat for a resumable multi-turn conversation. The toggle sits in the top
+ bar, not in a menu.
+- **Model picker** populated at runtime from the router's `/v1/models`. Models
+ with an `mmproj` file in `presets.ini` are marked with an eye.
+- **File attachment** by drag-and-drop anywhere on the window or through the
+ file dialog. Text and code files are inlined into the prompt, truncated to
+ fit the model's context with a warning when that happens. Images require a
+ vision model, and dropping one on a text model offers to switch.
+- **History** in SQLite with FTS5 full-text search. Past chat sessions reopen
+ and continue with their context intact; one-shot entries reopen read-only.
+- **Streaming replies** rendered token by token, formatted as markdown:
+ headings, bold and italic, bullet and numbered lists, tables, inline code
+ and tinted fenced code blocks. What you type is shown exactly as typed, so
+ the contents of an attached file are never reflowed.
+- **Collapsed reasoning.** Presets with a reasoning budget return the model's
+ 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.
+- **Tray icon** for show/hide/quit, hosted by waybar's tray module.
+
+Not in this version: RAG or embedding search over history, multi-user
+support, any cloud or non-local backend, remote access.
+
+## Requirements
+
+- Python 3.11 or newer (3.12 tested)
+- PySide6, httpx, Pillow
+- A llama.cpp server in router mode with an OpenAI-compatible API
+
+Pillow is only used for attachment thumbnails.
+
+## Installation
+
+PySide6 is not in SlackBuilds, so the simplest route on Slackware is a venv.
+`--system-site-packages` lets httpx and Pillow come from the system packages
+instead of being duplicated:
+
+```sh
+python3 -m venv --system-site-packages ~/.local/share/llamachat-venv
+~/.local/share/llamachat-venv/bin/pip install PySide6-Essentials
+```
+
+Then put the launcher on your PATH:
+
+```sh
+git clone <repo> ~/src/llamachat
+ln -s ~/src/llamachat/llamachat.py ~/bin/llamachat
+```
+
+The launcher starts under the system Python and hands over to an interpreter
+that has PySide6, so no activation step is needed and the command works from
+any directory. It looks for `.venv/` or `venv/` beside the checkout, then
+`~/.local/share/llamachat-venv/`. Set `LLAMACHAT_PYTHON` to name a specific
+interpreter, or `LLAMACHAT_NO_REEXEC=1` to disable the handover entirely.
+
+Install the desktop entry, which lets the portal resolve the application id
+and quiets a startup warning:
+
+```sh
+ln -s ~/src/llamachat/llamachat.desktop \
+ ~/.local/share/applications/llamachat.desktop
+```
+
+Verify:
+
+```sh
+llamachat --help
+cd ~/src/llamachat && ./test_llamachat.py
+```
+
+### Qt theming from a venv
+
+A pip-installed PySide6 bundles its own Qt build with almost no plugins, so
+`QT_QPA_PLATFORMTHEME=qt6ct` finds nothing to load and every window falls
+back to Fusion, ignoring qt6ct and Kvantum entirely.
+
+llamachat handles this at startup: if the system Qt6 is the *same version* as
+the one PySide6 bundles, it points `QT_PLUGIN_PATH` at the system plugin
+directory so the real platform theme loads. The version check matters,
+because a plugin built against a different Qt would crash rather than merely
+look wrong. Setting `QT_PLUGIN_PATH` yourself disables this and your value is
+used unchanged.
+
+Check what a mismatch would look like:
+
+```sh
+python3 -c 'from PySide6.QtCore import qVersion; print("bundled", qVersion())'
+ls /usr/lib64/libQt6Core.so.6.*
+```
+
+If those differ, the theme will not be picked up. Either install a PySide6
+matching the system Qt (`pip install "PySide6-Essentials==6.11.*"` for a
+6.11 system) or build PySide6 against the system Qt.
+
+## Configuration
+
+`~/.config/llamachat/config.toml` is written with defaults on first run.
+
+```toml
+base_url = "http://localhost:8181"
+presets = "/etc/llama-server/presets.ini"
+
+# Empty values take the XDG defaults:
+# socket -> $XDG_RUNTIME_DIR/llamachat.sock
+# db -> $XDG_DATA_HOME/llamachat/history.db
+socket = ""
+db = ""
+
+default_model = ""
+request_timeout = 300
+
+attach_ctx_fraction = 0.5
+chars_per_token = 3.5
+```
+
+`presets.ini` is read for two things the API does not report: which models
+have an `mmproj` file, and each model's `ctx-size`. Both `;` and `#` count as
+comment characters there, matching llama-server, so a commented-out `mmproj`
+line correctly reads as "no vision support".
+
+An attachment may fill `ctx_size * chars_per_token * attach_ctx_fraction`
+characters before it is truncated. With the defaults and a 32k model that is
+roughly 57000 characters. Models the router offers but `presets.ini` does not
+describe fall back to a conservative 4096-token assumption.
+
+## Usage
+
+```
+llamachat --daemon start the background instance
+llamachat --toggle show if hidden, hide if visible
+llamachat --show show the window
+llamachat --hide hide the window
+llamachat --ping exit 0 when an instance is running
+llamachat --quit stop the running instance
+```
+
+The control commands do not import Qt, so they return in well under a tenth
+of a second, which is what makes them usable behind a keybind.
+
+In the window: **Ctrl+Enter** sends, **Esc** hides, **New** starts a fresh
+conversation, right-clicking a history entry offers to delete it.
+
+## Hyprland configuration
+
+For Hyprland 0.55+ with the Lua config system. Paths below assume the
+`~/.config/hypr/sections/` layout.
+
+### Autostart
+
+In `sections/autostart.lua`, inside the existing `hl.on("hyprland.start", ...)`
+block:
+
+```lua
+hl.exec_cmd("llamachat --daemon")
+```
+
+### Keybind
+
+In `sections/keybindings.lua`:
+
+```lua
+hl.bind(mainMod .. " + Home", hl.dsp.exec_cmd("llamachat --toggle"))
+```
+
+### Window rules
+
+In `sections/look_and_feel.lua`:
+
+```lua
+-- llamachat: floating scratchpad-style window
+hl.window_rule({
+ name = "llamachat-float",
+ match = { class = "llamachat" },
+ float = true,
+})
+hl.window_rule({
+ name = "llamachat-size",
+ match = { class = "llamachat" },
+ size = { 1000, 700 },
+})
+hl.window_rule({
+ name = "llamachat-center",
+ match = { class = "llamachat" },
+ center = true,
+})
+```
+
+If `center` is not supported by your Hyprland Lua plugin, pin the geometry
+instead. `move` is monitor-relative, so for a 2560x1080 DP-1 and a 1000x700
+window the offsets are x=(2560-1000)/2=780 and y=(1080-700)/2=190:
+
+```lua
+hl.window_rule({
+ name = "llamachat-monitor",
+ match = { class = "llamachat" },
+ monitor = "DP-1",
+})
+hl.window_rule({
+ name = "llamachat-move",
+ match = { class = "llamachat" },
+ move = { 780, 190 },
+})
+```
+
+The window class is `llamachat`. Class matching in this plugin is a
+regex/substring match, so no escaping is needed.
+
+Reload with `hyprctl reload` after editing. Window rules apply at window-open
+time, so if the daemon was already running when you added them, restart it
+with `llamachat --quit` followed by `llamachat --daemon`.
+
+## Architecture
+
+```
+llamachat.py launcher, venv shebang, symlink target
+llamachat/
+ __main__.py argument dispatch, daemon, tray, socket event loop
+ config.py config.toml and presets.ini parsing
+ backend.py httpx streaming client, attachment loading
+ db.py SQLite schema, FTS5 search
+ ui.py the window
+test_llamachat.py self-checks for everything except the GUI
+```
+
+### Control protocol
+
+One JSON object per line over a Unix socket, one response line per request.
+The socket is created mode 0600 in `$XDG_RUNTIME_DIR`.
+
+```
+-> {"cmd": "toggle"} <- {"ok": true, "visible": false}
+-> {"cmd": "show"} <- {"ok": true, "visible": true}
+-> {"cmd": "hide"} <- {"ok": true, "visible": false}
+-> {"cmd": "ping"} <- {"ok": true}
+-> {"cmd": "quit"} <- {"ok": true}
+```
+
+Anything else answers `{"ok": false, "error": "..."}`. A socket left behind by
+a crashed instance is detected with a ping and removed at startup. The
+listening socket is serviced by a `QSocketNotifier` on the Qt event loop, so
+there is no second thread.
+
+### Database schema
+
+```sql
+sessions (id, mode, title, model, 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
+```
+
+Three triggers keep the FTS index in step with inserts, updates and deletes,
+which matters because a streamed reply is inserted empty and updated once it
+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.
+
+Attachment *content* lives inline in `messages.content`, since that is what
+was actually sent to the model. The `attachments` table records provenance:
+the original path, size, and a SHA-256 so a file can be identified even if it
+was later moved or renamed, plus a 128px JPEG thumbnail for images so a
+reopened conversation still shows what was sent even when the original file
+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.
+
+### Markdown rendering
+
+Replies are parsed by `QTextDocument.setMarkdown()`, so there is no markdown
+dependency. It runs in the GitHub dialect with the `MarkdownNoHTML` flag,
+which means markup a model emits is displayed literally rather than being
+interpreted: a reply containing `<img src=x onerror=...>` shows that text and
+nothing executes.
+
+Markdown links are still real links, so a reply cannot forge one that drives
+the interface: anchors using the internal `x-llamachat-reasoning:` scheme are
+rewritten before display, and only `http`, `https` and `mailto` links are
+handed to the desktop opener.
+
+Qt renders a fenced block as one `<pre>` element per line with no background
+of its own, so llamachat adds the tint that makes a code block read as one.
+
+## Notes and limits
+
+- Switching models makes the router unload the previous one. The first token
+ after a switch can take several seconds; the window shows a waiting state
+ rather than pretending it is instant.
+- Reasoning is read from the `reasoning_content` field the router sends
+ alongside `content`, not by parsing `<think>` tags out of the reply. How
+ much of it a model produces is set by `reasoning-budget` in `presets.ini`;
+ set that to `0` for a preset that should not think at all.
+- The attachment budget is an estimate based on a characters-per-token ratio,
+ not a real tokeniser. It is deliberately conservative.
+- The tray icon needs a StatusNotifierItem host. With waybar, that means the
+ `tray` module in your config. Without one, the icon silently does nothing
+ and the keybind still works.
+
+## License
+
+GPL-2.0-only. See [LICENSE](LICENSE).
+
+Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+
+## Development Approach
+
+This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.
+
+All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.
+
+The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
diff --git a/initial-prompt.md b/initial-prompt.md
new file mode 100644
index 0000000..413e559
--- /dev/null
+++ b/initial-prompt.md
@@ -0,0 +1,98 @@
+# Original project brief
+
+This is the brief llamachat was built from, kept for historical reference.
+It describes the v1 target; see the README for what the code actually does
+and the CHANGELOG for what changed since.
+
+## Context
+
+The backend is a llama.cpp server running in router mode, configured through
+a `presets.ini` file that defines multiple models, including at least one
+vision-capable model paired with an mmproj file. The server exposes an
+OpenAI-compatible API on localhost.
+
+The target desktop is Slackware with Hyprland on Wayland, using qt6ct and
+Kvantum for Qt theming.
+
+## Goals
+
+- A lightweight PySide6 (Qt6) desktop app, not a browser-based or Electron
+ solution. It should pick up the system Qt theme rather than fighting it.
+- Toggled via a global keyboard shortcut bound in Hyprland, behaving like a
+ scratchpad: press once to show, press again to hide. This requires the
+ app to run as a single persistent background process that listens on a
+ Unix domain socket for a toggle signal, rather than spawning a new
+ process/window per invocation.
+- Two chat modes:
+ 1. **One-shot**: quick single question/answer, no multi-turn context.
+ 2. **Chat**: persistent multi-turn conversation, resumable.
+ Both are saved to history and searchable. The UI should make the mode
+ switch obvious and easy (e.g. a toggle or tab, not buried in a menu).
+- **Model selection**: a dropdown/picker populated from the models
+ currently available on the llama.cpp router (query `/v1/models` or
+ equivalent at runtime, do not hardcode model names).
+- **File attachment**: both drag-and-drop onto the chat window and a
+ standard file picker dialog.
+ - Text/code files: read content and inject into context (with reasonable
+ size limits and a truncation warning if the file is too large for the
+ active model's context window).
+ - Image files: only valid when a vision-capable (mmproj-paired) model is
+ selected. If an image is dropped while a non-vision model is active,
+ prompt to switch to the vision preset (this triggers a router-side
+ unload/reload, so show a "loading model..." state, expect a
+ multi-second delay, do not treat it as instant).
+- **History**: stored in SQLite, covering both one-shot and chat-mode
+ sessions. Needs:
+ - A browsable list (most recent first) that can be scrolled and reopened.
+ - Full-text search across past conversations (use SQLite FTS5).
+ - Reopening a past chat-mode conversation should allow continuing it
+ (append new messages with prior context intact). Reopening a one-shot
+ entry is read-only reference, no continuation needed.
+ - No automatic "model searches its own history" / RAG feature in this
+ version. Explicitly out of scope for v1.
+- **Streaming responses**: use the OpenAI-compatible streaming endpoint so
+ responses render token-by-token, not as one blocking call.
+
+## Non-goals for v1
+
+- No RAG / embedding-based history search.
+- No multi-user support.
+- No cloud fallback or non-local model support.
+- No mobile/remote access, this is a local desktop tool only.
+
+## Technical constraints
+
+- Python 3, PySide6 for GUI.
+- `httpx` (with streaming support) for talking to the llama.cpp OpenAI-
+ compatible API.
+- SQLite (stdlib `sqlite3`) with an FTS5 virtual table for search.
+- Unix domain socket for IPC between the Hyprland keybind and the running
+ app instance (toggle show/hide). Provide the small client-side command
+ the Hyprland keybind should call.
+- Config (backend URL, socket path, default model, etc.) in a simple file,
+ not hardcoded, e.g. `~/.config/llamachat/config.toml`.
+- Target platform is Slackware/Hyprland/Wayland specifically, so avoid
+ anything X11-only or assuming GNOME/KDE session services are present.
+- The Hyprland config is written in **Lua** (Hyprland 0.55+, which replaced
+ the older hyprlang `.conf` format). Use Lua syntax throughout (keybinds,
+ window rules, and the autostart entry as Lua function calls), not
+ old-style `key = value` hyprlang lines. Do not assume backward
+ compatibility with hyprlang is present, target Lua only.
+
+## Process
+
+1. Confirm the llama.cpp router port/endpoint and presets.ini model naming
+ convention before assuming them.
+2. Propose the SQLite schema (tables for sessions, messages, attachments)
+ and the FTS5 setup before implementing.
+3. Propose the Unix socket protocol (message format for toggle/show/hide,
+ and any future commands) before implementing.
+4. Provide the Hyprland config snippets needed (window rule for
+ floating/scratchpad behavior, autostart line, keybind) as part of the
+ deliverable, not just the Python app.
+5. Keep the UI minimal: model picker, mode toggle, chat/input area,
+ drag-drop zone, history sidebar or panel. No feature creep beyond what
+ is listed above.
+
+Work through this iteratively: propose the architecture and schema first,
+review, then implement.
diff --git a/llamachat.desktop b/llamachat.desktop
new file mode 100644
index 0000000..2fb35fa
--- /dev/null
+++ b/llamachat.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Type=Application
+Name=llamachat
+GenericName=Local LLM Chat
+Comment=Chat client for a local llama.cpp router
+Exec=llamachat --toggle
+Icon=user-available
+Terminal=false
+Categories=Utility;Chat;
+Keywords=llm;chat;llama;ai;
+StartupWMClass=llamachat
+NoDisplay=false
diff --git a/llamachat.py b/llamachat.py
new file mode 100755
index 0000000..55214c2
--- /dev/null
+++ b/llamachat.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+# 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.
+"""Launcher. Symlink this into a directory on your PATH.
+
+PySide6 usually lives in a virtualenv rather than the system Python, so this
+re-executes itself with the venv interpreter when it finds one. Set
+LLAMACHAT_PYTHON to point at a specific interpreter, otherwise the venv
+beside the checkout and the default location are tried in turn.
+"""
+
+import sys
+from pathlib import Path
+
+# Resolve through the symlink so the package is found next to the real file.
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+
+import llamachat_venv # noqa: E402
+
+# The control commands do not need Qt, so only pay for the handover when a
+# command that builds a window is about to run.
+_NO_QT = {"--toggle", "--show", "--hide", "--ping", "--quit", "--version",
+ "--help", "-h"}
+if not _NO_QT & set(sys.argv[1:]):
+ llamachat_venv.reexec(__file__)
+
+from llamachat.__main__ import main # noqa: E402
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/llamachat/__init__.py b/llamachat/__init__.py
new file mode 100644
index 0000000..34974b8
--- /dev/null
+++ b/llamachat/__init__.py
@@ -0,0 +1,15 @@
+# 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.
+
+__version__ = "0.1.0"
diff --git a/llamachat/__main__.py b/llamachat/__main__.py
new file mode 100644
index 0000000..0faf88a
--- /dev/null
+++ b/llamachat/__main__.py
@@ -0,0 +1,267 @@
+# 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.
+"""Entry point.
+
+The control commands (--toggle, --show, --hide, --ping, --quit) answer
+without importing Qt, so a keybind press stays cheap.
+"""
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+from . import __version__, config, ipc
+
+# Where a distribution keeps its Qt6 plugins. Checked in order.
+SYSTEM_QT_PLUGIN_DIRS = (
+ "/usr/lib64/qt6/plugins",
+ "/usr/lib/qt6/plugins",
+ "/usr/lib/x86_64-linux-gnu/qt6/plugins",
+)
+
+USAGE = f"""\
+llamachat {__version__} - chat client for a local llama.cpp router
+
+ llamachat --daemon start the background instance (Hyprland autostart)
+ llamachat --toggle show the window if hidden, hide it if visible
+ llamachat --show show the window
+ llamachat --hide hide the window
+ llamachat --ping exit 0 when an instance is running
+ llamachat --quit stop the running instance
+ llamachat --version print the version and exit
+"""
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument("--daemon", action="store_true")
+ parser.add_argument("--toggle", action="store_true")
+ parser.add_argument("--show", action="store_true")
+ parser.add_argument("--hide", action="store_true")
+ parser.add_argument("--ping", action="store_true")
+ parser.add_argument("--quit", action="store_true")
+ parser.add_argument("-h", "--help", action="store_true")
+ parser.add_argument("--version", action="store_true")
+ args = parser.parse_args(argv)
+
+ if args.help:
+ print(USAGE)
+ return 0
+
+ if args.version:
+ print(f"llamachat {__version__}")
+ return 0
+
+ cfg = config.load()
+
+ for name in ("toggle", "show", "hide", "ping", "quit"):
+ if getattr(args, name):
+ return _control(cfg, name)
+
+ return _run_daemon(cfg)
+
+
+def _control(cfg, command: str) -> int:
+ """Send one command to the running instance."""
+ if not cfg.socket_path.exists():
+ print("llamachat is not running", file=sys.stderr)
+ return 1
+ try:
+ reply = ipc.send(cfg.socket_path, command)
+ except OSError as exc:
+ print(f"llamachat is not reachable: {exc}", file=sys.stderr)
+ return 1
+ if not reply.get("ok"):
+ print(reply.get("error", "command failed"), file=sys.stderr)
+ return 1
+ return 0
+
+
+def _system_qt_version() -> str:
+ """Qt6 version of the system install, or '' when it cannot be told."""
+ for directory in SYSTEM_QT_PLUGIN_DIRS:
+ path = Path(directory)
+ if not path.is_dir():
+ continue
+ # libQt6Core.so.6.11.1 -> 6.11.1
+ for libdir in (path.parent.parent, Path("/usr/lib64"), Path("/usr/lib")):
+ for lib in sorted(libdir.glob("libQt6Core.so.6.*")):
+ suffix = lib.name.split("libQt6Core.so.", 1)[1]
+ if suffix.count(".") == 2:
+ return suffix
+ return ""
+
+
+def _use_system_qt_theme() -> None:
+ """Let the system platform theme plugins load into a venv PySide6.
+
+ A pip-installed PySide6 bundles its own Qt but almost no plugins, so
+ QT_QPA_PLATFORMTHEME=qt6ct finds nothing and everything falls back to
+ Fusion. Adding the system plugin directory fixes that, but only when
+ the two Qt builds are the same version: loading a plugin built against
+ a different Qt would crash rather than look wrong.
+ """
+ if os.environ.get("QT_PLUGIN_PATH"):
+ return # the user has already chosen; do not override
+
+ from PySide6.QtCore import qVersion
+
+ system = _system_qt_version()
+ if not system or system != qVersion():
+ return
+
+ for directory in SYSTEM_QT_PLUGIN_DIRS:
+ if Path(directory, "platformthemes").is_dir():
+ os.environ["QT_PLUGIN_PATH"] = directory
+ return
+
+
+def _run_daemon(cfg) -> int:
+ """Start the persistent instance: window hidden, tray icon, socket."""
+ ipc.clear_stale(cfg.socket_path)
+ if cfg.socket_path.exists():
+ print("llamachat is already running", file=sys.stderr)
+ return 1
+
+ # Must happen before QApplication exists, or the theme plugin is never
+ # consulted.
+ _use_system_qt_theme()
+
+ # Heavy imports live here so the control path above stays fast.
+ from PySide6.QtCore import QSocketNotifier, Qt
+ from PySide6.QtGui import QAction, QIcon, QPixmap, QPainter, QColor, QFont
+ from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
+
+ from .backend import Client
+ from .db import History
+ from .ui import ChatWindow
+
+ config.write_default()
+
+ app = QApplication(sys.argv)
+ app.setApplicationName("llamachat")
+ app.setDesktopFileName("llamachat")
+ app.setQuitOnLastWindowClosed(False)
+
+ history = History(cfg.db_path)
+ client = Client(cfg.base_url, cfg.request_timeout)
+ presets = config.parse_presets(cfg.presets_path)
+ window = ChatWindow(cfg, history, client, presets)
+
+ def toggle() -> bool:
+ if window.isVisible():
+ window.hide()
+ else:
+ show()
+ return window.isVisible()
+
+ def show() -> None:
+ window.show()
+ window.raise_()
+ window.activateWindow()
+ window.input.setFocus()
+
+ # Tray icon. Waybar's tray module hosts it.
+ tray = QSystemTrayIcon(_icon(QPixmap, QPainter, QColor, QFont, QIcon), app)
+ tray.setToolTip("llamachat")
+ menu = QMenu()
+ show_action = QAction("Show / Hide", menu)
+ show_action.triggered.connect(toggle)
+ menu.addAction(show_action)
+ menu.addSeparator()
+ quit_action = QAction("Quit", menu)
+ quit_action.triggered.connect(app.quit)
+ menu.addAction(quit_action)
+ tray.setContextMenu(menu)
+ tray.activated.connect(
+ lambda reason: toggle() if reason == QSystemTrayIcon.Trigger else None
+ )
+ tray.show()
+
+ # Socket, serviced by the Qt event loop rather than a second thread.
+ server = ipc.create_server(cfg.socket_path)
+
+ def on_connection() -> None:
+ try:
+ conn, _ = server.accept()
+ except OSError:
+ return
+ try:
+ command = ipc.read_request(conn)
+ if command == "toggle":
+ ipc.write_reply(conn, {"ok": True, "visible": toggle()})
+ elif command == "show":
+ show()
+ ipc.write_reply(conn, {"ok": True, "visible": True})
+ elif command == "hide":
+ window.hide()
+ ipc.write_reply(conn, {"ok": True, "visible": False})
+ elif command == "ping":
+ ipc.write_reply(conn, {"ok": True})
+ elif command == "quit":
+ ipc.write_reply(conn, {"ok": True})
+ app.quit()
+ else:
+ ipc.write_reply(
+ conn, {"ok": False, "error": f"unknown cmd: {command}"}
+ )
+ finally:
+ conn.close()
+
+ notifier = QSocketNotifier(server.fileno(), QSocketNotifier.Read, app)
+ notifier.activated.connect(on_connection)
+
+ def cleanup() -> None:
+ notifier.setEnabled(False)
+ server.close()
+ cfg.socket_path.unlink(missing_ok=True)
+ history.close()
+
+ app.aboutToQuit.connect(cleanup)
+
+ try:
+ return app.exec()
+ finally:
+ cleanup()
+
+
+def _icon(QPixmap, QPainter, QColor, QFont, QIcon):
+ """A drawn tray icon, so there is no icon file to install."""
+ pixmap = QPixmap(64, 64)
+ pixmap.fill(QColor(0, 0, 0, 0))
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setBrush(QColor("#3F5EFB"))
+ painter.setPen(QColor("#3F5EFB"))
+ painter.drawRoundedRect(4, 8, 56, 40, 8, 8)
+ painter.drawPolygon([_pt(16, 46), _pt(32, 46), _pt(18, 60)])
+ painter.setPen(QColor("white"))
+ font = QFont()
+ font.setPixelSize(26)
+ font.setBold(True)
+ painter.setFont(font)
+ painter.drawText(pixmap.rect().adjusted(0, -8, 0, -8), 0x0084, "λ")
+ painter.end()
+ return QIcon(pixmap)
+
+
+def _pt(x: int, y: int):
+ from PySide6.QtCore import QPoint
+
+ return QPoint(x, y)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/llamachat/backend.py b/llamachat/backend.py
new file mode 100644
index 0000000..89cb044
--- /dev/null
+++ b/llamachat/backend.py
@@ -0,0 +1,237 @@
+# 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.
+"""Talking to the llama.cpp router over its OpenAI-compatible API."""
+
+import base64
+import hashlib
+import io
+import json
+import mimetypes
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import httpx
+from PIL import Image
+
+THUMB_SIZE = (128, 128)
+
+# Extensions we are willing to read as text even though mimetypes does not
+# call them text/*.
+CODE_SUFFIXES = {
+ ".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx",
+ ".tsx", ".sh", ".bash", ".zsh", ".lua", ".rb", ".pl", ".php", ".java",
+ ".kt", ".swift", ".sql", ".toml", ".yaml", ".yml", ".json", ".ini",
+ ".cfg", ".conf", ".md", ".rst", ".txt", ".csv", ".xml", ".html", ".css",
+ ".diff", ".patch", ".SlackBuild", ".info", ".desktop", ".service",
+}
+
+IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
+
+# Sentinel yielded by the SSE parser at the end of a stream. A distinct
+# object rather than '' so an empty delta cannot be mistaken for the end.
+DONE = ("done", "")
+
+
+class BackendError(Exception):
+ """Any failure talking to the router, already phrased for the user."""
+
+
+@dataclass
+class Attachment:
+ """A file the user attached, ready for both the API and the database."""
+ path: Path
+ kind: str # 'text' | 'image'
+ mime: str
+ size: int
+ sha256: str
+ text: str = "" # kind == 'text': file content, possibly truncated
+ data_url: str = "" # kind == 'image': base64 data: URL for the API
+ thumb: bytes | None = None
+ truncated: bool = False
+
+
+def classify(path: Path) -> str:
+ """Decide whether a path is a text or image attachment."""
+ mime, _ = mimetypes.guess_type(path.name)
+ if mime in IMAGE_MIMES:
+ return "image"
+ if path.suffix in CODE_SUFFIXES:
+ return "text"
+ if mime and mime.startswith("text/"):
+ return "text"
+ return "unknown"
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def load_attachment(path: Path, char_budget: int) -> Attachment:
+ """Read a file into an Attachment, truncating text past char_budget."""
+ path = path.expanduser().resolve()
+ kind = classify(path)
+ if kind == "unknown":
+ raise BackendError(f"Unsupported file type: {path.name}")
+
+ size = path.stat().st_size
+ mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
+ att = Attachment(
+ path=path, kind=kind, mime=mime, size=size, sha256=_sha256(path)
+ )
+
+ if kind == "text":
+ raw = path.read_text(encoding="utf-8", errors="replace")
+ if len(raw) > char_budget:
+ att.text = raw[:char_budget]
+ att.truncated = True
+ else:
+ att.text = raw
+ return att
+
+ raw = path.read_bytes()
+ att.data_url = f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
+ att.thumb = _thumbnail(path)
+ return att
+
+
+def _thumbnail(path: Path) -> bytes | None:
+ """A small JPEG preview, so reopened chats still show what was sent."""
+ try:
+ with Image.open(path) as img:
+ img = img.convert("RGB")
+ img.thumbnail(THUMB_SIZE)
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=80)
+ return buf.getvalue()
+ except Exception:
+ # ponytail: a missing preview is cosmetic, never fail an attach over it
+ return None
+
+
+def build_user_content(text: str, attachments: list[Attachment]):
+ """Assemble one OpenAI-format user message from text plus attachments.
+
+ Returns a plain string when there is nothing but text, and the
+ multi-part content array when images are involved.
+ """
+ parts: list[str] = []
+ for att in attachments:
+ if att.kind != "text":
+ continue
+ note = " (truncated)" if att.truncated else ""
+ parts.append(
+ f"--- file: {att.path.name}{note} ---\n{att.text}\n--- end file ---"
+ )
+ if text:
+ parts.append(text)
+ joined = "\n\n".join(parts)
+
+ images = [a for a in attachments if a.kind == "image"]
+ if not images:
+ return joined
+
+ content: list[dict] = [{"type": "text", "text": joined}]
+ for att in images:
+ content.append(
+ {"type": "image_url", "image_url": {"url": att.data_url}}
+ )
+ return content
+
+
+class Client:
+ """Minimal OpenAI-compatible client for the local router."""
+
+ def __init__(self, base_url: str, timeout: int = 300):
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+
+ def models(self) -> list[str]:
+ """Model ids the router currently offers."""
+ try:
+ resp = httpx.get(f"{self.base_url}/v1/models", timeout=10)
+ resp.raise_for_status()
+ payload = resp.json()
+ except httpx.HTTPError as exc:
+ raise BackendError(f"Cannot reach router at {self.base_url}: {exc}")
+ except ValueError as exc:
+ raise BackendError(f"Router sent invalid JSON: {exc}")
+ return [m["id"] for m in payload.get("data", []) if "id" in m]
+
+ def stream_chat(self, model: str, messages: list[dict]):
+ """Yield (kind, text) pairs as the model produces them.
+
+ `kind` is 'reasoning' for the model's thinking and 'content' for the
+ reply proper. Presets with a reasoning budget emit the former in a
+ separate `reasoning_content` delta field, which is what lets the UI
+ keep the two apart.
+
+ Loading a different model makes the router unload the previous one,
+ so the first chunk can take several seconds. That wait happens
+ inside the initial `stream()` call.
+ """
+ body = {"model": model, "messages": messages, "stream": True}
+ try:
+ with httpx.stream(
+ "POST",
+ f"{self.base_url}/v1/chat/completions",
+ json=body,
+ timeout=httpx.Timeout(self.timeout, connect=10),
+ ) as resp:
+ if resp.status_code != 200:
+ resp.read()
+ raise BackendError(
+ f"Router returned {resp.status_code}: {resp.text[:300]}"
+ )
+ for line in resp.iter_lines():
+ chunk = _parse_sse_line(line)
+ if chunk is None:
+ continue
+ if chunk == DONE:
+ return
+ yield chunk
+ except httpx.HTTPError as exc:
+ raise BackendError(f"Request failed: {exc}")
+
+
+def _parse_sse_line(line: str) -> tuple[str, str] | None:
+ """Decode one SSE line.
+
+ Returns a ('reasoning'|'content', text) pair, the DONE sentinel at the
+ end of the stream, or None for lines carrying nothing to render.
+ """
+ if not line.startswith("data:"):
+ return None
+ payload = line[5:].strip()
+ if payload == "[DONE]":
+ return DONE
+ try:
+ obj = json.loads(payload)
+ except ValueError:
+ return None
+ choices = obj.get("choices") or []
+ if not choices:
+ return None
+ delta = choices[0].get("delta") or {}
+
+ reasoning = delta.get("reasoning_content")
+ if reasoning:
+ return ("reasoning", reasoning)
+ content = delta.get("content")
+ if content:
+ return ("content", content)
+ return None
diff --git a/llamachat/config.py b/llamachat/config.py
new file mode 100644
index 0000000..fac72d5
--- /dev/null
+++ b/llamachat/config.py
@@ -0,0 +1,167 @@
+# 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.
+"""Config file loading and llama-server presets.ini parsing."""
+
+import configparser
+import os
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+
+CONFIG_PATH = Path(
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
+) / "llamachat" / "config.toml"
+
+DEFAULTS = {
+ "base_url": "http://localhost:8181",
+ "presets": "/etc/llama-server/presets.ini",
+ "socket": "", # empty -> $XDG_RUNTIME_DIR/llamachat.sock
+ "db": "", # empty -> $XDG_DATA_HOME/llamachat/history.db
+ "default_model": "",
+ "request_timeout": 300,
+ # Fraction of a model's context we allow a single attachment to fill.
+ "attach_ctx_fraction": 0.5,
+ # Rough chars-per-token used to turn a ctx-size into a char budget.
+ "chars_per_token": 3.5,
+}
+
+
+@dataclass
+class Preset:
+ """One [section] of presets.ini, reduced to what the UI needs."""
+ name: str
+ vision: bool
+ ctx_size: int
+
+ def char_budget(self, fraction: float, chars_per_token: float) -> int:
+ return int(self.ctx_size * chars_per_token * fraction)
+
+
+@dataclass
+class Config:
+ base_url: str
+ presets_path: Path
+ socket_path: Path
+ db_path: Path
+ default_model: str
+ request_timeout: int
+ attach_ctx_fraction: float
+ chars_per_token: float
+
+
+def _runtime_dir() -> Path:
+ rd = os.environ.get("XDG_RUNTIME_DIR")
+ if rd:
+ return Path(rd)
+ # ponytail: /tmp fallback only matters on a broken session; no uid-dir
+ # creation dance, XDG_RUNTIME_DIR is set on any real Wayland login.
+ return Path("/tmp")
+
+
+def _data_dir() -> Path:
+ return Path(
+ os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
+ ) / "llamachat"
+
+
+def load(path: Path = CONFIG_PATH) -> Config:
+ """Read config.toml, falling back to DEFAULTS for anything absent."""
+ values = dict(DEFAULTS)
+ if path.exists():
+ with open(path, "rb") as fh:
+ values.update(tomllib.load(fh))
+
+ socket = values["socket"] or (_runtime_dir() / "llamachat.sock")
+ db = values["db"] or (_data_dir() / "history.db")
+
+ return Config(
+ base_url=str(values["base_url"]).rstrip("/"),
+ presets_path=Path(values["presets"]).expanduser(),
+ socket_path=Path(str(socket)).expanduser(),
+ db_path=Path(str(db)).expanduser(),
+ default_model=str(values["default_model"]),
+ request_timeout=int(values["request_timeout"]),
+ attach_ctx_fraction=float(values["attach_ctx_fraction"]),
+ chars_per_token=float(values["chars_per_token"]),
+ )
+
+
+def write_default(path: Path = CONFIG_PATH) -> Path:
+ """Create a commented config.toml if none exists. Returns the path."""
+ if path.exists():
+ return path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "# llamachat configuration\n"
+ f'base_url = "{DEFAULTS["base_url"]}"\n'
+ f'presets = "{DEFAULTS["presets"]}"\n'
+ '\n'
+ '# Leave empty for XDG defaults:\n'
+ '# socket -> $XDG_RUNTIME_DIR/llamachat.sock\n'
+ '# db -> $XDG_DATA_HOME/llamachat/history.db\n'
+ 'socket = ""\n'
+ 'db = ""\n'
+ '\n'
+ '# Model selected at startup. Empty picks the first available.\n'
+ 'default_model = ""\n'
+ '\n'
+ '# Seconds before a generation request is abandoned.\n'
+ f'request_timeout = {DEFAULTS["request_timeout"]}\n'
+ '\n'
+ '# How much of the model context one attachment may fill, and the\n'
+ '# chars-per-token estimate used to convert ctx-size into characters.\n'
+ f'attach_ctx_fraction = {DEFAULTS["attach_ctx_fraction"]}\n'
+ f'chars_per_token = {DEFAULTS["chars_per_token"]}\n'
+ )
+ return path
+
+
+def parse_presets(path: Path) -> dict[str, Preset]:
+ """Parse llama-server's presets.ini.
+
+ Both ';' and '#' start a comment there, so a commented-out `#mmproj`
+ line correctly reads as "this preset has no vision support".
+ """
+ parser = configparser.ConfigParser(
+ comment_prefixes=(";", "#"),
+ inline_comment_prefixes=(";", "#"),
+ strict=False,
+ )
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return {}
+
+ # presets.ini opens with a bare `version = 1` before any section, which
+ # configparser rejects. Give those leading keys a section to live in.
+ try:
+ parser.read_string("[__file__]\n" + text)
+ except configparser.Error:
+ return {}
+
+ presets: dict[str, Preset] = {}
+ for name in parser.sections():
+ if name == "__file__":
+ continue
+ section = parser[name]
+ try:
+ ctx = int(section.get("ctx-size", "4096"))
+ except ValueError:
+ ctx = 4096
+ presets[name] = Preset(
+ name=name,
+ vision=bool(section.get("mmproj", "").strip()),
+ ctx_size=ctx,
+ )
+ return presets
diff --git a/llamachat/db.py b/llamachat/db.py
new file mode 100644
index 0000000..d746a0e
--- /dev/null
+++ b/llamachat/db.py
@@ -0,0 +1,254 @@
+# 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.
+"""SQLite history store with FTS5 search."""
+
+import sqlite3
+import time
+from pathlib import Path
+
+SCHEMA = """
+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
+);
+
+CREATE TABLE IF NOT EXISTS messages (
+ id INTEGER PRIMARY KEY,
+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ reasoning TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id);
+CREATE INDEX IF NOT EXISTS idx_sessions_updated
+ ON sessions(updated_at DESC, id DESC);
+
+CREATE TABLE IF NOT EXISTS attachments (
+ id INTEGER PRIMARY KEY,
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
+ path TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ mime TEXT,
+ size INTEGER,
+ sha256 TEXT,
+ thumb BLOB,
+ truncated INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
+ content,
+ content='messages',
+ content_rowid='id',
+ tokenize='unicode61'
+);
+
+CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
+ INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
+END;
+
+CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content)
+ VALUES ('delete', old.id, old.content);
+END;
+
+CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content)
+ VALUES ('delete', old.id, old.content);
+ INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
+END;
+"""
+
+
+def _now() -> int:
+ return int(time.time())
+
+
+class History:
+ """Session/message/attachment storage. One connection, one thread."""
+
+ def __init__(self, path: Path):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ self.conn = sqlite3.connect(path)
+ self.conn.row_factory = sqlite3.Row
+ self.conn.executescript(SCHEMA)
+ self._migrate()
+ self.conn.commit()
+
+ 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)")
+ }
+ if "reasoning" not in columns:
+ self.conn.execute(
+ "ALTER TABLE messages ADD COLUMN reasoning TEXT NOT NULL"
+ " DEFAULT ''"
+ )
+
+ def close(self) -> None:
+ self.conn.close()
+
+ # -- sessions ---------------------------------------------------------
+
+ def create_session(self, mode: str, model: str, title: str = "") -> int:
+ now = _now()
+ cur = self.conn.execute(
+ "INSERT INTO sessions (mode, title, model, created_at, updated_at)"
+ " VALUES (?, ?, ?, ?, ?)",
+ (mode, title, model, now, now),
+ )
+ self.conn.commit()
+ return int(cur.lastrowid)
+
+ def touch_session(self, session_id: int, model: str | None = None) -> None:
+ if model:
+ self.conn.execute(
+ "UPDATE sessions SET updated_at = ?, model = ? WHERE id = ?",
+ (_now(), model, session_id),
+ )
+ else:
+ self.conn.execute(
+ "UPDATE sessions SET updated_at = ? WHERE id = ?",
+ (_now(), session_id),
+ )
+ self.conn.commit()
+
+ def set_title(self, session_id: int, title: str) -> None:
+ self.conn.execute(
+ "UPDATE sessions SET title = ? WHERE id = ?", (title, session_id)
+ )
+ self.conn.commit()
+
+ def get_session(self, session_id: int) -> sqlite3.Row | None:
+ return self.conn.execute(
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
+ ).fetchone()
+
+ def recent_sessions(self, limit: int = 200) -> list[sqlite3.Row]:
+ # id breaks ties: several sessions can share a one-second timestamp.
+ return self.conn.execute(
+ "SELECT * FROM sessions ORDER BY updated_at DESC, id DESC LIMIT ?",
+ (limit,),
+ ).fetchall()
+
+ def delete_session(self, session_id: int) -> None:
+ self.conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
+ self.conn.commit()
+
+ # -- messages ---------------------------------------------------------
+
+ def add_message(self, session_id: int, role: str, content: str) -> int:
+ cur = self.conn.execute(
+ "INSERT INTO messages (session_id, role, content, created_at)"
+ " VALUES (?, ?, ?, ?)",
+ (session_id, role, content, _now()),
+ )
+ self.conn.execute(
+ "UPDATE sessions SET updated_at = ? WHERE id = ?",
+ (_now(), session_id),
+ )
+ self.conn.commit()
+ return int(cur.lastrowid)
+
+ def update_message(
+ self, message_id: int, content: str, reasoning: str | None = None
+ ) -> None:
+ if reasoning is None:
+ self.conn.execute(
+ "UPDATE messages SET content = ? WHERE id = ?",
+ (content, message_id),
+ )
+ else:
+ self.conn.execute(
+ "UPDATE messages SET content = ?, reasoning = ? WHERE id = ?",
+ (content, reasoning, message_id),
+ )
+ self.conn.commit()
+
+ def messages(self, session_id: int) -> list[sqlite3.Row]:
+ return self.conn.execute(
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,)
+ ).fetchall()
+
+ # -- attachments ------------------------------------------------------
+
+ def add_attachment(
+ self,
+ message_id: int,
+ path: str,
+ kind: str,
+ mime: str = "",
+ size: int = 0,
+ sha256: str = "",
+ thumb: bytes | None = None,
+ truncated: bool = False,
+ ) -> int:
+ cur = self.conn.execute(
+ "INSERT INTO attachments"
+ " (message_id, path, kind, mime, size, sha256, thumb, truncated)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (message_id, path, kind, mime, size, sha256, thumb, int(truncated)),
+ )
+ self.conn.commit()
+ return int(cur.lastrowid)
+
+ def attachments(self, message_id: int) -> list[sqlite3.Row]:
+ return self.conn.execute(
+ "SELECT * FROM attachments WHERE message_id = ? ORDER BY id",
+ (message_id,),
+ ).fetchall()
+
+ # -- search -----------------------------------------------------------
+
+ def search(self, query: str, limit: int = 100) -> list[sqlite3.Row]:
+ """Full-text search over message content.
+
+ Returns one row per matching message, newest first, carrying the
+ owning session so the UI can offer to reopen it.
+ """
+ if not query.strip():
+ return []
+ return self.conn.execute(
+ "SELECT m.id AS message_id, m.session_id, m.role, m.created_at,"
+ " s.title, s.mode, s.model,"
+ " snippet(messages_fts, 0, '<b>', '</b>', '…', 16) AS snip"
+ " FROM messages_fts"
+ " JOIN messages m ON m.id = messages_fts.rowid"
+ " JOIN sessions s ON s.id = m.session_id"
+ " WHERE messages_fts MATCH ?"
+ " ORDER BY m.created_at DESC, m.id DESC LIMIT ?",
+ (_fts_query(query), limit),
+ ).fetchall()
+
+
+def _fts_query(text: str) -> str:
+ """Turn user input into a safe FTS5 query.
+
+ Every token is quoted, so punctuation that FTS5 would read as syntax
+ (a bare '-', 'AND', a stray quote) cannot raise OperationalError.
+ """
+ tokens = [t.replace('"', '""') for t in text.split() if t]
+ return " ".join(f'"{t}"' for t in tokens)
diff --git a/llamachat/ipc.py b/llamachat/ipc.py
new file mode 100644
index 0000000..99b1f25
--- /dev/null
+++ b/llamachat/ipc.py
@@ -0,0 +1,109 @@
+# 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.
+"""Unix-socket control channel.
+
+Protocol: one JSON object per line, one response line per request.
+
+ -> {"cmd": "toggle"} <- {"ok": true, "visible": false}
+ -> {"cmd": "show"} <- {"ok": true, "visible": true}
+ -> {"cmd": "hide"} <- {"ok": true, "visible": false}
+ -> {"cmd": "ping"} <- {"ok": true}
+ -> {"cmd": "quit"} <- {"ok": true}
+
+Unknown commands answer {"ok": false, "error": "..."}.
+
+This module imports nothing from Qt, so the client path stays fast enough
+to sit behind a keybind.
+"""
+
+import json
+import socket
+from pathlib import Path
+
+COMMANDS = ("toggle", "show", "hide", "ping", "quit")
+
+
+def send(socket_path: Path, cmd: str, timeout: float = 2.0) -> dict:
+ """Send one command to a running instance and return its reply."""
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.settimeout(timeout)
+ try:
+ sock.connect(str(socket_path))
+ sock.sendall((json.dumps({"cmd": cmd}) + "\n").encode())
+ buf = b""
+ while not buf.endswith(b"\n"):
+ chunk = sock.recv(4096)
+ if not chunk:
+ break
+ buf += chunk
+ finally:
+ sock.close()
+ if not buf:
+ return {"ok": False, "error": "no reply"}
+ try:
+ return json.loads(buf.decode().strip())
+ except ValueError as exc:
+ return {"ok": False, "error": f"bad reply: {exc}"}
+
+
+def is_running(socket_path: Path) -> bool:
+ """True when a live instance answers on the socket."""
+ if not socket_path.exists():
+ return False
+ try:
+ return bool(send(socket_path, "ping").get("ok"))
+ except OSError:
+ return False
+
+
+def clear_stale(socket_path: Path) -> None:
+ """Remove a socket file left behind by a crashed instance."""
+ if socket_path.exists() and not is_running(socket_path):
+ socket_path.unlink(missing_ok=True)
+
+
+def create_server(socket_path: Path) -> socket.socket:
+ """Bind and listen. Caller must have cleared any stale socket first."""
+ socket_path.parent.mkdir(parents=True, exist_ok=True)
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ srv.bind(str(socket_path))
+ socket_path.chmod(0o600)
+ srv.listen(4)
+ srv.setblocking(False)
+ return srv
+
+
+def read_request(conn: socket.socket) -> str:
+ """Read one command name from a connection. '' when unreadable."""
+ conn.settimeout(2.0)
+ buf = b""
+ try:
+ while not buf.endswith(b"\n") and len(buf) < 4096:
+ chunk = conn.recv(4096)
+ if not chunk:
+ break
+ buf += chunk
+ except OSError:
+ return ""
+ try:
+ return str(json.loads(buf.decode().strip()).get("cmd", ""))
+ except ValueError:
+ return ""
+
+
+def write_reply(conn: socket.socket, payload: dict) -> None:
+ try:
+ conn.sendall((json.dumps(payload) + "\n").encode())
+ except OSError:
+ pass
diff --git a/llamachat/ui.py b/llamachat/ui.py
new file mode 100644
index 0000000..d8ec10e
--- /dev/null
+++ b/llamachat/ui.py
@@ -0,0 +1,902 @@
+# 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.
+"""The chat window."""
+
+import html
+import re
+from pathlib import Path
+
+from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot
+from PySide6.QtGui import (
+ QAction, QDesktopServices, QKeySequence, QTextDocument,
+)
+from PySide6.QtWidgets import (
+ QButtonGroup, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
+ QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox,
+ QPlainTextEdit, QPushButton, QRadioButton, QSplitter, QTextBrowser,
+ QVBoxLayout, QWidget,
+)
+
+from . import backend
+from .backend import Attachment, BackendError
+
+MODE_ONESHOT = "oneshot"
+MODE_CHAT = "chat"
+
+# URL scheme for the reasoning toggle. A reply is untrusted text, so links
+# it produces are stripped rather than trusted to not collide with this.
+REASONING_SCHEME = "x-llamachat-reasoning:"
+
+
+class StreamWorker(QObject):
+ """Runs one streaming request off the GUI thread."""
+
+ chunk = Signal(str)
+ reasoning = Signal(str)
+ finished = Signal()
+ failed = Signal(str)
+
+ def __init__(self, client, model: str, messages: list[dict]):
+ super().__init__()
+ self.client = client
+ self.model = model
+ self.messages = messages
+ self._stop = False
+
+ def stop(self) -> None:
+ self._stop = True
+
+ @Slot()
+ def run(self) -> None:
+ try:
+ for kind, piece in self.client.stream_chat(self.model, self.messages):
+ if self._stop:
+ break
+ if kind == "reasoning":
+ self.reasoning.emit(piece)
+ else:
+ self.chunk.emit(piece)
+ except BackendError as exc:
+ self.failed.emit(str(exc))
+ return
+ except Exception as exc: # noqa: BLE001 - surface anything as UI text
+ self.failed.emit(f"Unexpected error: {exc}")
+ return
+ self.finished.emit()
+
+
+class ChatWindow(QMainWindow):
+ """Model picker, mode toggle, transcript, input, history panel."""
+
+ def __init__(self, cfg, history, client, presets):
+ super().__init__()
+ self.cfg = cfg
+ self.history = history
+ self.client = client
+ self.presets = presets
+
+ self.session_id: int | None = None
+ self.mode = MODE_ONESHOT
+ self.read_only = False
+ self.attachments: list[Attachment] = []
+ self.thread: QThread | None = None
+ self.worker: StreamWorker | None = None
+ self.assistant_message_id: int | None = None
+ self.assistant_buffer = ""
+ self.reasoning_buffer = ""
+ # Every rendered bubble, so a reasoning toggle can redraw the
+ # transcript without refetching anything.
+ self.bubbles: list[dict] = []
+ self.expanded: set[int] = set()
+
+ self.setWindowTitle("llamachat")
+ self.resize(1000, 700)
+ self.setAcceptDrops(True)
+ self._build_ui()
+ self.refresh_models()
+ self.refresh_history()
+
+ # -- construction -----------------------------------------------------
+
+ def _build_ui(self) -> None:
+ central = QWidget()
+ outer = QVBoxLayout(central)
+
+ # Top bar: model, mode, search.
+ top = QHBoxLayout()
+ 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.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")
+ self.oneshot_radio.setChecked(True)
+ group = QButtonGroup(self)
+ group.addButton(self.oneshot_radio)
+ group.addButton(self.chat_radio)
+ self.oneshot_radio.toggled.connect(self._on_mode_changed)
+ top.addWidget(self.oneshot_radio)
+ top.addWidget(self.chat_radio)
+
+ top.addStretch()
+ self.search_box = QLineEdit()
+ self.search_box.setPlaceholderText("Search history…")
+ self.search_box.setClearButtonEnabled(True)
+ self.search_box.setMaximumWidth(280)
+ self.search_box.textChanged.connect(self._on_search)
+ top.addWidget(self.search_box)
+ outer.addLayout(top)
+
+ # Body: history list beside the transcript and input.
+ splitter = QSplitter(Qt.Horizontal)
+
+ self.history_list = QListWidget()
+ self.history_list.setMinimumWidth(200)
+ self.history_list.itemActivated.connect(self._on_history_activated)
+ self.history_list.itemClicked.connect(self._on_history_activated)
+ self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.history_list.customContextMenuRequested.connect(
+ self._on_history_menu
+ )
+ splitter.addWidget(self.history_list)
+
+ right = QWidget()
+ right_layout = QVBoxLayout(right)
+ right_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.transcript = QTextBrowser()
+ # Links are handled here, not followed: reasoning:N toggles a
+ # thinking block, anything else opens in the browser.
+ self.transcript.setOpenLinks(False)
+ self.transcript.anchorClicked.connect(self._on_anchor_clicked)
+ right_layout.addWidget(self.transcript, 1)
+
+ self.attach_label = QLabel()
+ self.attach_label.setWordWrap(True)
+ self.attach_label.hide()
+ right_layout.addWidget(self.attach_label)
+
+ self.status_label = QLabel()
+ self.status_label.hide()
+ right_layout.addWidget(self.status_label)
+
+ self.input = QPlainTextEdit()
+ self.input.setPlaceholderText(
+ "Type a message. Ctrl+Enter sends, Esc hides the window."
+ )
+ self.input.setMaximumHeight(140)
+ right_layout.addWidget(self.input)
+
+ buttons = QHBoxLayout()
+ self.new_button = QPushButton("New")
+ self.new_button.clicked.connect(self.new_session)
+ buttons.addWidget(self.new_button)
+
+ self.attach_button = QPushButton("Attach…")
+ self.attach_button.clicked.connect(self._on_attach_clicked)
+ buttons.addWidget(self.attach_button)
+
+ self.clear_attach_button = QPushButton("Clear files")
+ 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.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)
+ right_layout.addLayout(buttons)
+
+ splitter.addWidget(right)
+ splitter.setStretchFactor(0, 0)
+ splitter.setStretchFactor(1, 1)
+ splitter.setSizes([240, 760])
+ outer.addWidget(splitter, 1)
+
+ self.setCentralWidget(central)
+
+ send_action = QAction(self)
+ send_action.setShortcut(QKeySequence("Ctrl+Return"))
+ send_action.triggered.connect(self.send)
+ self.addAction(send_action)
+
+ send_enter = QAction(self)
+ send_enter.setShortcut(QKeySequence("Ctrl+Enter"))
+ send_enter.triggered.connect(self.send)
+ self.addAction(send_enter)
+
+ # -- model handling ---------------------------------------------------
+
+ def refresh_models(self) -> None:
+ """Repopulate the picker from the router, keeping the selection."""
+ previous = self.model_box.currentText()
+ try:
+ available = self.client.models()
+ except BackendError as exc:
+ self.show_status(str(exc), error=True)
+ return
+
+ self.model_box.blockSignals(True)
+ self.model_box.clear()
+ for name in available:
+ preset = self.presets.get(name)
+ label = f"{name} 👁" if preset and preset.vision else name
+ self.model_box.addItem(label, name)
+ self.model_box.blockSignals(False)
+
+ target = previous or self.cfg.default_model
+ if target:
+ index = self.model_box.findData(_strip_marker(target))
+ if index < 0:
+ index = self.model_box.findText(target)
+ if index >= 0:
+ self.model_box.setCurrentIndex(index)
+ if available:
+ self.hide_status()
+
+ def current_model(self) -> str:
+ return self.model_box.currentData() or self.model_box.currentText()
+
+ def current_preset(self):
+ return self.presets.get(self.current_model())
+
+ def char_budget(self) -> int:
+ preset = self.current_preset()
+ if preset is None:
+ return int(4096 * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction)
+ return preset.char_budget(
+ self.cfg.attach_ctx_fraction, self.cfg.chars_per_token
+ )
+
+ def vision_models(self) -> list[str]:
+ names = []
+ for i in range(self.model_box.count()):
+ name = self.model_box.itemData(i)
+ preset = self.presets.get(name)
+ if preset and preset.vision:
+ names.append(name)
+ return names
+
+ 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())
+
+ # -- mode handling ----------------------------------------------------
+
+ def _on_mode_changed(self, _checked: bool) -> None:
+ new_mode = MODE_ONESHOT if self.oneshot_radio.isChecked() else MODE_CHAT
+ if new_mode == self.mode:
+ return
+ self.mode = new_mode
+ self.new_session()
+
+ def new_session(self) -> None:
+ """Drop the current conversation and start clean."""
+ self.session_id = None
+ self.read_only = False
+ self.assistant_message_id = None
+ self.assistant_buffer = ""
+ self.reasoning_buffer = ""
+ self.bubbles.clear()
+ self.expanded.clear()
+ self.clear_attachments()
+ self.transcript.clear()
+ self.input.clear()
+ self.input.setReadOnly(False)
+ self.send_button.setEnabled(True)
+ self.history_list.clearSelection()
+ self.hide_status()
+
+ # -- attachments ------------------------------------------------------
+
+ def _on_attach_clicked(self) -> None:
+ paths, _ = QFileDialog.getOpenFileNames(self, "Attach files")
+ if paths:
+ self.add_attachments([Path(p) for p in paths])
+
+ def add_attachments(self, paths: list[Path]) -> None:
+ """Load files, refusing images unless the model can see them."""
+ wants_vision = any(backend.classify(p) == "image" for p in paths)
+ if wants_vision and not self._ensure_vision_model():
+ return
+
+ for path in paths:
+ try:
+ att = backend.load_attachment(path, self.char_budget())
+ except (BackendError, OSError) as exc:
+ self.show_status(f"{path.name}: {exc}", error=True)
+ continue
+ self.attachments.append(att)
+ if att.truncated:
+ self.show_status(
+ f"{path.name} was truncated to fit the model context "
+ f"({self.char_budget()} characters).",
+ error=True,
+ )
+ self.update_attach_label()
+
+ def _ensure_vision_model(self) -> bool:
+ """Offer to switch to a vision preset. True when one is active."""
+ preset = self.current_preset()
+ if preset and preset.vision:
+ return True
+
+ candidates = self.vision_models()
+ if not candidates:
+ QMessageBox.warning(
+ self,
+ "No vision model",
+ "No model on the router has an mmproj file configured, so "
+ "images cannot be sent.",
+ )
+ return False
+
+ target = candidates[0]
+ answer = QMessageBox.question(
+ self,
+ "Switch model?",
+ f"Images need a vision-capable model.\n\n"
+ f"Switch to {target}?\n\n"
+ "The router unloads the current model to do this, so the next "
+ "reply will take several seconds to start.",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.Yes,
+ )
+ if answer != QMessageBox.Yes:
+ return False
+
+ index = self.model_box.findData(target)
+ if index >= 0:
+ self.model_box.setCurrentIndex(index)
+ return True
+
+ def clear_attachments(self) -> None:
+ self.attachments.clear()
+ self.update_attach_label()
+
+ def update_attach_label(self) -> None:
+ if not self.attachments:
+ self.attach_label.hide()
+ self.clear_attach_button.hide()
+ return
+ names = []
+ for att in self.attachments:
+ mark = "🖼" if att.kind == "image" else "📄"
+ suffix = " (truncated)" if att.truncated else ""
+ names.append(f"{mark} {att.path.name}{suffix}")
+ self.attach_label.setText("Attached: " + ", ".join(names))
+ self.attach_label.show()
+ self.clear_attach_button.show()
+
+ # -- drag and drop ----------------------------------------------------
+
+ def dragEnterEvent(self, event) -> None:
+ if event.mimeData().hasUrls():
+ event.acceptProposedAction()
+
+ def dragMoveEvent(self, event) -> None:
+ if event.mimeData().hasUrls():
+ event.acceptProposedAction()
+
+ def dropEvent(self, event) -> None:
+ paths = [
+ Path(url.toLocalFile())
+ for url in event.mimeData().urls()
+ if url.isLocalFile()
+ ]
+ if paths:
+ self.add_attachments(paths)
+ event.acceptProposedAction()
+
+ # -- sending ----------------------------------------------------------
+
+ def send(self) -> None:
+ if self.thread is not None:
+ return
+ if self.read_only:
+ self.show_status(
+ "One-shot entries are read-only. Press New to start again.",
+ error=True,
+ )
+ return
+
+ text = self.input.toPlainText().strip()
+ if not text and not self.attachments:
+ return
+ model = self.current_model()
+ if not model:
+ self.show_status("No model selected.", error=True)
+ return
+
+ 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
+ )
+
+ content = backend.build_user_content(text, self.attachments)
+ stored = content if isinstance(content, str) else content[0]["text"]
+ message_id = self.history.add_message(self.session_id, "user", stored)
+ for att in self.attachments:
+ self.history.add_attachment(
+ message_id,
+ path=str(att.path),
+ kind=att.kind,
+ mime=att.mime,
+ size=att.size,
+ sha256=att.sha256,
+ thumb=att.thumb,
+ truncated=att.truncated,
+ )
+
+ self.append_bubble("user", stored, self.attachments)
+ self.input.clear()
+
+ if self.mode == MODE_CHAT:
+ messages = self._chat_context(content)
+ else:
+ messages = [{"role": "user", "content": content}]
+
+ self.clear_attachments()
+ self.refresh_history()
+ self._start_stream(model, messages)
+
+ def _chat_context(self, latest_content) -> list[dict]:
+ """Prior turns plus the new message, for multi-turn mode."""
+ 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 _start_stream(self, model: str, messages: list[dict]) -> None:
+ self.assistant_buffer = ""
+ self.reasoning_buffer = ""
+ self.assistant_message_id = self.history.add_message(
+ self.session_id, "assistant", ""
+ )
+ self.append_bubble("assistant", "")
+ self.show_status(f"Waiting for {model}… (a model switch takes a while)")
+ self.send_button.setEnabled(False)
+ self.stop_button.show()
+
+ self.thread = QThread(self)
+ self.worker = StreamWorker(self.client, model, messages)
+ self.worker.moveToThread(self.thread)
+ self.thread.started.connect(self.worker.run)
+ self.worker.chunk.connect(self._on_chunk)
+ self.worker.reasoning.connect(self._on_reasoning)
+ self.worker.finished.connect(self._on_stream_finished)
+ self.worker.failed.connect(self._on_stream_failed)
+ self.thread.start()
+
+ @Slot(str)
+ def _on_chunk(self, piece: str) -> None:
+ if not self.assistant_buffer:
+ self.hide_status()
+ self.assistant_buffer += piece
+ self._update_last_bubble(text=self.assistant_buffer)
+
+ @Slot(str)
+ def _on_reasoning(self, piece: str) -> None:
+ if not self.reasoning_buffer:
+ self.hide_status()
+ self.reasoning_buffer += piece
+ self._update_last_bubble(reasoning=self.reasoning_buffer)
+
+ @Slot()
+ def _on_stream_finished(self) -> None:
+ if self.assistant_message_id is not None:
+ self.history.update_message(
+ self.assistant_message_id,
+ self.assistant_buffer,
+ self.reasoning_buffer,
+ )
+ self._teardown_stream()
+ self.refresh_history()
+
+ @Slot(str)
+ def _on_stream_failed(self, message: str) -> None:
+ if self.assistant_message_id is not None:
+ self.history.update_message(
+ self.assistant_message_id,
+ self.assistant_buffer,
+ self.reasoning_buffer,
+ )
+ self.show_status(message, error=True)
+ self._teardown_stream()
+
+ def stop_stream(self) -> None:
+ if self.worker is not None:
+ self.worker.stop()
+
+ def _teardown_stream(self) -> None:
+ if self.thread is not None:
+ self.thread.quit()
+ self.thread.wait(3000)
+ self.thread = None
+ self.worker = None
+ self.assistant_message_id = None
+ self.send_button.setEnabled(True)
+ self.stop_button.hide()
+
+ # -- transcript rendering ---------------------------------------------
+
+ def append_bubble(
+ self,
+ role: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ saved_rows=None,
+ reasoning: str = "",
+ ) -> None:
+ self.bubbles.append(
+ {
+ "role": role,
+ "text": text,
+ "attachments": attachments,
+ "saved_rows": saved_rows,
+ "reasoning": reasoning,
+ }
+ )
+ self._render()
+
+ def _update_last_bubble(
+ self, text: str | None = None, reasoning: str | None = None
+ ) -> None:
+ """Rewrite the streaming assistant bubble."""
+ if not self.bubbles:
+ return
+ if text is not None:
+ self.bubbles[-1]["text"] = text
+ if reasoning is not None:
+ self.bubbles[-1]["reasoning"] = reasoning
+ self._render(live=True)
+
+ def _render(self, live: bool = False) -> None:
+ """Redraw the whole transcript.
+
+ Rewriting everything keeps the collapse state and the streaming
+ update on one code path. Conversations are short enough that the
+ cost does not show.
+ """
+ bar = self.transcript.verticalScrollBar()
+ at_end = bar.value() >= bar.maximum() - 4
+ previous = bar.value()
+
+ parts = []
+ for i, bubble in enumerate(self.bubbles):
+ streaming = live and i == len(self.bubbles) - 1
+ parts.append(
+ _bubble_html(
+ bubble["role"],
+ bubble["text"],
+ bubble["attachments"],
+ bubble["saved_rows"],
+ reasoning=bubble["reasoning"],
+ index=i,
+ expanded=i in self.expanded,
+ live=streaming and not bubble["text"],
+ )
+ )
+ self.transcript.setHtml("".join(parts))
+
+ if at_end:
+ self._scroll_to_end()
+ else:
+ bar.setValue(previous)
+
+ def _on_anchor_clicked(self, url) -> None:
+ """Expand or collapse a thinking block."""
+ target = url.toString()
+ if target.startswith("blocked:"):
+ return # a link a reply tried to forge
+ if not target.startswith(REASONING_SCHEME):
+ if url.scheme() in ("http", "https", "mailto"):
+ QDesktopServices.openUrl(url)
+ return
+ try:
+ index = int(target[len(REASONING_SCHEME) :])
+ except ValueError:
+ return
+ if index in self.expanded:
+ self.expanded.discard(index)
+ else:
+ self.expanded.add(index)
+ self._render(live=self.thread is not None)
+
+ def _scroll_to_end(self) -> None:
+ bar = self.transcript.verticalScrollBar()
+ bar.setValue(bar.maximum())
+
+ # -- history panel ----------------------------------------------------
+
+ def refresh_history(self) -> None:
+ if self.search_box.text().strip():
+ return # search results are showing; leave them alone
+ self.history_list.clear()
+ for row in self.history.recent_sessions():
+ marker = "💬" if row["mode"] == MODE_CHAT else "⚡"
+ item = QListWidgetItem(f"{marker} {row['title'] or '(untitled)'}")
+ item.setData(Qt.UserRole, row["id"])
+ item.setToolTip(f"{row['model'] or '?'} — {row['mode']}")
+ self.history_list.addItem(item)
+
+ def _on_search(self, text: str) -> None:
+ if not text.strip():
+ self.refresh_history()
+ return
+ self.history_list.clear()
+ for row in self.history.search(text):
+ item = QListWidgetItem(
+ f"{row['title'] or '(untitled)'}\n {_plain(row['snip'])}"
+ )
+ item.setData(Qt.UserRole, row["session_id"])
+ self.history_list.addItem(item)
+
+ def _on_history_activated(self, item: QListWidgetItem) -> None:
+ session_id = item.data(Qt.UserRole)
+ if session_id is not None:
+ self.open_session(int(session_id))
+
+ def _on_history_menu(self, point) -> None:
+ item = self.history_list.itemAt(point)
+ if item is None:
+ return
+ menu = QMenu(self)
+ delete = menu.addAction("Delete")
+ if menu.exec(self.history_list.mapToGlobal(point)) == delete:
+ self.history.delete_session(int(item.data(Qt.UserRole)))
+ if self.session_id == int(item.data(Qt.UserRole)):
+ self.new_session()
+ self.refresh_history()
+
+ def open_session(self, session_id: int) -> None:
+ """Load a past conversation. Chat mode continues, one-shot is read-only."""
+ session = self.history.get_session(session_id)
+ if session is None:
+ return
+
+ self.session_id = session_id
+ self.mode = session["mode"]
+ self.read_only = self.mode == MODE_ONESHOT
+ self.clear_attachments()
+
+ self.oneshot_radio.blockSignals(True)
+ self.chat_radio.blockSignals(True)
+ self.oneshot_radio.setChecked(self.mode == MODE_ONESHOT)
+ self.chat_radio.setChecked(self.mode == MODE_CHAT)
+ self.oneshot_radio.blockSignals(False)
+ self.chat_radio.blockSignals(False)
+
+ if session["model"]:
+ index = self.model_box.findData(session["model"])
+ if index >= 0:
+ self.model_box.setCurrentIndex(index)
+
+ self.bubbles.clear()
+ self.expanded.clear()
+ for row in self.history.messages(session_id):
+ self.bubbles.append(
+ {
+ "role": row["role"],
+ "text": row["content"],
+ "attachments": None,
+ "saved_rows": self.history.attachments(row["id"]),
+ "reasoning": _column(row, "reasoning"),
+ }
+ )
+ self._render()
+ self._scroll_to_end()
+
+ self.input.setReadOnly(self.read_only)
+ self.send_button.setEnabled(not self.read_only)
+ if self.read_only:
+ self.show_status(
+ "One-shot entry, read-only. Press New to start a fresh chat."
+ )
+ else:
+ self.hide_status()
+
+ # -- status line ------------------------------------------------------
+
+ def show_status(self, text: str, error: bool = False) -> None:
+ self.status_label.setText(text)
+ self.status_label.setStyleSheet(
+ "color: palette(bright-text); background: palette(highlight); "
+ "padding: 4px; border-radius: 3px;"
+ if error
+ else "padding: 4px;"
+ )
+ self.status_label.show()
+
+ def hide_status(self) -> None:
+ self.status_label.hide()
+
+ # -- window behaviour -------------------------------------------------
+
+ def keyPressEvent(self, event) -> None:
+ if event.key() == Qt.Key_Escape:
+ self.hide()
+ return
+ super().keyPressEvent(event)
+
+ def closeEvent(self, event) -> None:
+ """Closing hides; the daemon keeps running for the next toggle."""
+ event.ignore()
+ self.hide()
+
+
+# GitHub dialect for tables and strikethrough; NoHTML so markup in a reply
+# is shown literally instead of being interpreted.
+MARKDOWN_FLAGS = (
+ QTextDocument.MarkdownDialectGitHub | QTextDocument.MarkdownNoHTML
+)
+
+# Qt renders fenced code as one <pre> per line with no background of its
+# own. Giving those a tint is what makes a code block read as a block.
+_PRE_STYLE = (
+ "background:rgba(127,127,127,0.18);padding:1px 6px;margin:0;"
+ "font-family:monospace"
+)
+
+
+def _markdown_to_fragment(text: str) -> str:
+ """Render markdown to an HTML fragment safe to splice into the page.
+
+ QTextDocument does the parsing, so there is no markdown dependency. Its
+ output is a whole document, and only the body content is wanted here.
+ """
+ if not text:
+ return ""
+ doc = QTextDocument()
+ doc.setMarkdown(text, MARKDOWN_FLAGS)
+ html_text = doc.toHtml()
+
+ start = html_text.find("<body")
+ if start == -1:
+ return html.escape(text).replace("\n", "<br>")
+ start = html_text.find(">", start)
+ end = html_text.rfind("</body>")
+ if start == -1 or end == -1:
+ return html.escape(text).replace("\n", "<br>")
+ fragment = html_text[start + 1 : end].strip()
+
+ # A reply could contain [x](x-llamachat-reasoning:0), which markdown
+ # turns into a real anchor. Defuse those so only the toggles this code
+ # emits can drive the UI.
+ fragment = fragment.replace(f'href="{REASONING_SCHEME}', 'href="blocked:')
+
+ # Tint code blocks, which Qt leaves unstyled. Qt emits one <pre> per
+ # line, so the tint has to land on every one of them to read as a block.
+ return _PRE_RE.sub(_tint_pre, fragment)
+
+
+_PRE_RE = re.compile(r"<pre(\s+style=\"([^\"]*)\")?\s*>")
+
+
+def _tint_pre(match: re.Match) -> str:
+ existing = (match.group(2) or "").strip()
+ if existing and not existing.endswith(";"):
+ existing += ";"
+ return f'<pre style="{existing}{_PRE_STYLE}">'
+
+
+def _column(row, name: str, default: str = "") -> str:
+ """Read a column that may predate a schema migration."""
+ try:
+ value = row[name]
+ except (IndexError, KeyError):
+ return default
+ return value if value is not None else default
+
+
+def _strip_marker(name: str) -> str:
+ return name.replace(" 👁", "").strip()
+
+
+def _plain(text: str) -> str:
+ """Flatten an FTS snippet into one line of plain text."""
+ return (
+ text.replace("<b>", "").replace("</b>", "").replace("\n", " ")[:80]
+ )
+
+
+def _reasoning_html(text: str, index: int, expanded: bool, live: bool) -> str:
+ """The thinking block: a clickable summary line, body only when open.
+
+ `index` identifies which bubble the toggle link belongs to, so several
+ replies in one transcript expand independently.
+ """
+ if not text:
+ return ""
+ lines = text.count("\n") + 1
+ arrow = "▾" if expanded else "▸"
+ label = "thinking…" if live else f"thinking ({len(text)} chars, {lines} lines)"
+ header = (
+ f'<a href="{REASONING_SCHEME}{index}" style="color:palette(mid);'
+ f'text-decoration:none">{arrow} {label}</a>'
+ )
+ if not expanded:
+ return f'<div style="margin:2px 0">{header}</div>'
+ body = html.escape(text).replace("\n", "<br>")
+ return (
+ f'<div style="margin:2px 0">{header}</div>'
+ f'<div style="margin:2px 0 6px 14px;color:palette(mid);'
+ f'font-style:italic">{body}</div>'
+ )
+
+
+def _bubble_html(
+ role: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ saved_rows=None,
+ reasoning: str = "",
+ index: int = 0,
+ expanded: bool = False,
+ live: bool = False,
+) -> str:
+ """One transcript entry as HTML."""
+ label = {"user": "You", "assistant": "Model"}.get(role, role)
+ colour = "palette(highlight)" if role == "user" else "palette(mid)"
+
+ # Replies are markdown; what the user typed is shown as typed, so an
+ # attached file's contents cannot be reflowed into headings and lists.
+ if role == "assistant":
+ body = _markdown_to_fragment(text)
+ else:
+ body = html.escape(text).replace("\n", "<br>")
+
+ files = ""
+ if attachments:
+ names = ", ".join(html.escape(a.path.name) for a in attachments)
+ files = f"<br><i>files: {names}</i>"
+ elif saved_rows:
+ parts = []
+ for row in saved_rows:
+ missing = "" if Path(row["path"]).exists() else " (missing)"
+ parts.append(html.escape(row["path"]) + missing)
+ files = f"<br><i>files: {', '.join(parts)}</i>"
+
+ think = _reasoning_html(reasoning, index, expanded, live)
+
+ if role == "assistant":
+ # Markdown produces block elements, so the speaker label sits on its
+ # own line rather than trying to lead the first paragraph.
+ return (
+ f'<div style="margin:6px 0">{think}'
+ f'<div style="color:{colour}"><b>{label}:</b></div>'
+ f"{body}{files}</div>"
+ )
+ return (
+ f'<div style="margin:6px 0">{think}'
+ f'<b style="color:{colour}">{label}:</b> {body}{files}</div>'
+ )
diff --git a/llamachat_venv.py b/llamachat_venv.py
new file mode 100644
index 0000000..68fd981
--- /dev/null
+++ b/llamachat_venv.py
@@ -0,0 +1,81 @@
+# 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.
+"""Finding the interpreter that has PySide6.
+
+PySide6 is usually installed in a virtualenv rather than the system Python,
+so a script started with `#!/usr/bin/env python3` has to hand over to that
+interpreter. Keeping the search here means the launcher and the test script
+agree on where to look, and neither hardcodes a path.
+
+`LLAMACHAT_PYTHON` names an interpreter explicitly. `LLAMACHAT_NO_REEXEC`
+disables the handover, which is also how the re-executed process avoids
+looping.
+"""
+
+import os
+import sys
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+
+# Checked in order; the first interpreter that exists wins.
+CANDIDATES = (
+ HERE / ".venv" / "bin" / "python3",
+ HERE / "venv" / "bin" / "python3",
+ Path.home() / ".local" / "share" / "llamachat-venv" / "bin" / "python3",
+)
+
+
+def have_pyside() -> bool:
+ try:
+ import PySide6 # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def find_interpreter() -> Path | None:
+ """The first candidate interpreter that exists and is not this one.
+
+ Paths are compared unresolved on purpose. A venv created with
+ --system-site-packages has a bin/python3 that symlinks to the system
+ interpreter, so resolving both sides would make the venv look identical
+ to the interpreter we are trying to escape. What differs is sys.prefix,
+ which follows from the path used to start it, not from the real binary.
+ """
+ override = os.environ.get("LLAMACHAT_PYTHON")
+ candidates = [Path(override)] if override else list(CANDIDATES)
+ current = Path(sys.executable)
+ for candidate in candidates:
+ if candidate.is_file() and candidate != current:
+ return candidate
+ return None
+
+
+def reexec(script: str) -> None:
+ """Restart `script` under an interpreter that has PySide6.
+
+ Returns normally when the current interpreter is already usable, when
+ the handover is disabled, or when no other interpreter was found; in
+ that last case the caller fails later with a clear ImportError.
+ """
+ if os.environ.get("LLAMACHAT_NO_REEXEC") or have_pyside():
+ return
+ interpreter = find_interpreter()
+ if interpreter is None:
+ return
+ # Set before exec so the new process cannot bounce again.
+ os.environ["LLAMACHAT_NO_REEXEC"] = "1"
+ path = str(Path(script).resolve())
+ os.execv(str(interpreter), [str(interpreter), path] + sys.argv[1:])
diff --git a/test_llamachat.py b/test_llamachat.py
new file mode 100755
index 0000000..4a113e0
--- /dev/null
+++ b/test_llamachat.py
@@ -0,0 +1,514 @@
+#!/usr/bin/env python3
+# 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.
+"""Self-checks for the non-GUI logic. Run: ./test_llamachat.py"""
+
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+# Some checks need PySide6, so reuse the launcher's venv discovery rather
+# than requiring the venv interpreter to be named on the command line.
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import llamachat_venv # noqa: E402
+
+llamachat_venv.reexec(__file__)
+
+from llamachat import backend, config, db
+
+PRESETS_SAMPLE = """\
+version = 1
+
+; A preset whose mmproj line is commented out -> not vision capable.
+#[Disabled-Model]
+#ngl = all
+#mmproj = /models/disabled/mmproj.gguf
+
+[vision-model]
+ngl = all
+ctx-size = 32768
+mmproj = /models/vision/mmproj.gguf
+
+[text-model]
+ngl = all
+ctx-size = 16384
+; --- Multimodal ---
+; WARNING: eats VRAM.
+#mmproj = /models/text/mmproj.gguf
+
+[no-ctx-model]
+ngl = all
+"""
+
+
+def test_presets():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "presets.ini"
+ path.write_text(PRESETS_SAMPLE)
+ presets = config.parse_presets(path)
+
+ # A '#'-commented section must not appear at all.
+ assert "Disabled-Model" not in presets, presets.keys()
+ assert set(presets) == {"vision-model", "text-model", "no-ctx-model"}
+
+ # mmproj present -> vision; commented out -> not vision.
+ assert presets["vision-model"].vision is True
+ assert presets["text-model"].vision is False
+ assert presets["no-ctx-model"].vision is False
+
+ assert presets["vision-model"].ctx_size == 32768
+ assert presets["text-model"].ctx_size == 16384
+ assert presets["no-ctx-model"].ctx_size == 4096 # default
+
+ # 32768 tokens * 3.5 chars * 0.5 -> 57344 chars
+ assert presets["vision-model"].char_budget(0.5, 3.5) == 57344
+
+ # A missing file yields no presets rather than raising.
+ assert config.parse_presets(Path("/nonexistent/presets.ini")) == {}
+ print("ok presets parsing")
+
+
+def test_real_presets():
+ """The user's actual file, if present, must classify as expected."""
+ path = Path("/etc/llama-server/presets.ini")
+ if not path.exists():
+ print("skip real presets (file absent)")
+ return
+ presets = config.parse_presets(path)
+ assert "gemma-4-12B-it" in presets
+ assert presets["gemma-4-12B-it"].vision is True
+ # Qwen3.5-9B has its mmproj line commented out with '#'.
+ if "Qwen3.5-9B" in presets:
+ assert presets["Qwen3.5-9B"].vision is False
+ print("ok real presets classification")
+
+
+def test_fts_query_escaping():
+ # Bare punctuation and FTS keywords must not become query syntax.
+ assert db._fts_query("hello world") == '"hello" "world"'
+ assert db._fts_query("foo AND bar") == '"foo" "AND" "bar"'
+ assert db._fts_query('say "hi"') == '"say" """hi"""'
+ assert db._fts_query("-flag") == '"-flag"'
+ assert db._fts_query(" ") == ""
+ print("ok fts query escaping")
+
+
+def test_history_roundtrip():
+ with tempfile.TemporaryDirectory() as tmp:
+ history = db.History(Path(tmp) / "test.db")
+
+ chat = history.create_session("chat", "vision-model", "About otters")
+ history.add_message(chat, "user", "Tell me about otters please")
+ history.add_message(chat, "assistant", "Otters are semiaquatic mammals")
+
+ shot = history.create_session("oneshot", "text-model", "Capital city")
+ history.add_message(shot, "user", "What is the capital of Italy")
+
+ rows = history.messages(chat)
+ assert len(rows) == 2
+ assert rows[0]["role"] == "user"
+
+ # Newest session first.
+ recent = history.recent_sessions()
+ assert len(recent) == 2
+ assert recent[0]["id"] == shot
+
+ # FTS finds content across sessions, and punctuation cannot break it.
+ assert len(history.search("otters")) == 2
+ assert len(history.search("capital")) == 1
+ assert history.search("zebra") == []
+ assert history.search('otters "AND') == [] # must not raise
+
+ # Attachments survive with enough detail to reconstruct context.
+ message_id = history.add_message(chat, "user", "look at this")
+ history.add_attachment(
+ message_id, "/home/u/pic.png", "image",
+ mime="image/png", size=1234, sha256="abc", thumb=b"\xff\xd8jpeg",
+ )
+ saved = history.attachments(message_id)
+ assert len(saved) == 1
+ assert saved[0]["path"] == "/home/u/pic.png"
+ assert saved[0]["kind"] == "image"
+ assert saved[0]["thumb"] == b"\xff\xd8jpeg"
+
+ # Editing a streamed message keeps the FTS index in step.
+ streamed = history.add_message(chat, "assistant", "")
+ history.update_message(streamed, "the platypus is unusual")
+ assert len(history.search("platypus")) == 1
+
+ # Deleting a session takes its messages out of search too.
+ history.delete_session(chat)
+ assert history.search("otters") == []
+ assert len(history.recent_sessions()) == 1
+
+ history.close()
+ print("ok history roundtrip")
+
+
+def test_attachment_truncation():
+ with tempfile.TemporaryDirectory() as tmp:
+ big = Path(tmp) / "big.py"
+ big.write_text("x" * 5000)
+
+ att = backend.load_attachment(big, char_budget=1000)
+ assert att.kind == "text"
+ assert att.truncated is True
+ assert len(att.text) == 1000
+ assert att.size == 5000
+ assert len(att.sha256) == 64
+
+ small = Path(tmp) / "small.txt"
+ small.write_text("hello")
+ att = backend.load_attachment(small, char_budget=1000)
+ assert att.truncated is False
+ assert att.text == "hello"
+
+ odd = Path(tmp) / "thing.bin"
+ odd.write_bytes(b"\x00\x01")
+ try:
+ backend.load_attachment(odd, char_budget=1000)
+ except backend.BackendError:
+ pass
+ else:
+ raise AssertionError("unsupported type should raise")
+ print("ok attachment truncation")
+
+
+def test_classify():
+ assert backend.classify(Path("a.py")) == "text"
+ assert backend.classify(Path("a.SlackBuild")) == "text"
+ assert backend.classify(Path("a.png")) == "image"
+ assert backend.classify(Path("a.jpg")) == "image"
+ assert backend.classify(Path("a.so")) == "unknown"
+ print("ok file classification")
+
+
+def test_sse_parsing():
+ line = 'data: {"choices":[{"delta":{"content":"hi"}}]}'
+ assert backend._parse_sse_line(line) == ("content", "hi")
+
+ # Reasoning arrives in its own field, which is what lets the UI keep
+ # thinking and reply apart without parsing <think> tags.
+ think = 'data: {"choices":[{"delta":{"reasoning_content":"hmm"}}]}'
+ assert backend._parse_sse_line(think) == ("reasoning", "hmm")
+
+ assert backend._parse_sse_line("data: [DONE]") == backend.DONE
+ assert backend._parse_sse_line(": keepalive") is None
+ assert backend._parse_sse_line("") is None
+ assert backend._parse_sse_line("data: {bad json") is None
+
+ # An opening delta of {'role': 'assistant', 'content': None} carries
+ # nothing to render and must not be mistaken for end-of-stream.
+ opening = backend._parse_sse_line(
+ 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}'
+ )
+ assert opening is None
+ assert opening != backend.DONE
+ print("ok sse parsing")
+
+
+def test_reasoning_storage():
+ with tempfile.TemporaryDirectory() as tmp:
+ history = db.History(Path(tmp) / "r.db")
+ sid = history.create_session("chat", "m", "t")
+
+ # A streamed reply is inserted empty, then filled in once done.
+ mid = history.add_message(sid, "assistant", "")
+ history.update_message(mid, "51", "the model's private thinking")
+
+ row = history.messages(sid)[0]
+ assert row["content"] == "51"
+ assert row["reasoning"] == "the model's private thinking"
+
+ # Reasoning must stay out of the search index, or thinking text
+ # would drown out real hits.
+ assert history.search("51") != []
+ assert history.search("private") == []
+
+ # Omitting the argument leaves stored reasoning untouched.
+ history.update_message(mid, "52")
+ assert history.messages(sid)[0]["reasoning"] == (
+ "the model's private thinking"
+ )
+ history.close()
+ print("ok reasoning storage")
+
+
+def test_migration_adds_reasoning():
+ """A database created before the reasoning column 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);"
+ "INSERT INTO messages VALUES (1,1,'user','older message',0);"
+ )
+ conn.commit()
+ conn.close()
+
+ history = db.History(path)
+ rows = history.messages(1)
+ assert rows[0]["content"] == "older message"
+ assert rows[0]["reasoning"] == "" # backfilled by the migration
+
+ mid = history.add_message(1, "assistant", "new")
+ history.update_message(mid, "new", "fresh thinking")
+ assert history.messages(1)[1]["reasoning"] == "fresh thinking"
+ history.close()
+ print("ok reasoning column migration")
+
+
+def test_user_content():
+ with tempfile.TemporaryDirectory() as tmp:
+ src = Path(tmp) / "code.py"
+ src.write_text("print(1)")
+ text_att = backend.load_attachment(src, 1000)
+
+ # Text only -> a plain string, file inlined before the prompt.
+ content = backend.build_user_content("explain", [text_att])
+ assert isinstance(content, str)
+ assert "print(1)" in content
+ assert content.endswith("explain")
+
+ # No attachments -> just the prompt.
+ assert backend.build_user_content("hi", []) == "hi"
+
+ # An image -> the multi-part array the vision API expects.
+ img = backend.Attachment(
+ path=Path("/x/a.png"), kind="image", mime="image/png",
+ size=1, sha256="", data_url="data:image/png;base64,AAA",
+ )
+ content = backend.build_user_content("what is this", [img])
+ assert isinstance(content, list)
+ assert content[0]["type"] == "text"
+ assert content[1]["type"] == "image_url"
+ assert content[1]["image_url"]["url"].startswith("data:image/png")
+ print("ok user content assembly")
+
+
+def test_markdown_rendering():
+ """Replies render as markdown; markup inside them stays literal."""
+ import os
+
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat.ui import _markdown_to_fragment
+
+ app = QApplication.instance() or QApplication([])
+ assert app is not None
+
+ fragment = _markdown_to_fragment(
+ "**bold** and *italic* and `code`\n\n"
+ "- a\n- b\n\n"
+ "| x | y |\n|---|---|\n| 1 | 2 |\n\n"
+ "```py\ndef f():\n pass\n```\n"
+ )
+ assert "font-weight:700" in fragment
+ assert "font-style:italic" in fragment
+ assert "<ul" in fragment
+ assert "<table" in fragment
+ assert "<pre" in fragment
+ # Every <pre> is tinted, since Qt emits one per line of a code block.
+ assert fragment.count("<pre") == fragment.count("background:rgba")
+ # The document wrapper must not leak into the fragment.
+ assert "<html" not in fragment and "<body" not in fragment
+
+ # Markup in a reply is shown, never interpreted.
+ hostile = _markdown_to_fragment(
+ 'Text <b>tag</b> <img src=x onerror=alert(1)> <a href="http://bad">l</a>'
+ )
+ assert "&lt;b&gt;" in hostile, hostile
+ assert "font-weight:700" not in hostile
+ # The URL may appear, but only as escaped text, never as a live anchor.
+ assert "<a href" not in hostile
+ assert "&lt;a href=&quot;http://bad&quot;&gt;" in hostile, hostile
+
+ # A markdown link is a real anchor, so check a reply cannot forge one
+ # aimed at the reasoning toggle.
+ from llamachat.ui import REASONING_SCHEME
+
+ forged = _markdown_to_fragment(f"[click]({REASONING_SCHEME}0)")
+ assert f'href="{REASONING_SCHEME}' not in forged, forged
+ assert 'href="blocked:' in forged, forged
+
+ # Ordinary markdown links still work.
+ link = _markdown_to_fragment("[site](http://example.com)")
+ assert "http://example.com" in link
+
+ # Empty input renders nothing rather than an empty document wrapper.
+ assert _markdown_to_fragment("") == ""
+
+ # Partial markdown arrives constantly while streaming and must not raise.
+ for partial in ("```", "```py", "```py\ndef f(", "| a |", "**unclosed", "#"):
+ _markdown_to_fragment(partial)
+ print("ok markdown rendering")
+
+
+def test_system_qt_theme_guard():
+ """The plugin path is only borrowed when the Qt versions agree."""
+ import os
+
+ from llamachat.__main__ import _system_qt_version, _use_system_qt_theme
+
+ # An explicit user setting is never overridden.
+ saved = os.environ.get("QT_PLUGIN_PATH")
+ try:
+ os.environ["QT_PLUGIN_PATH"] = "/user/choice"
+ _use_system_qt_theme()
+ assert os.environ["QT_PLUGIN_PATH"] == "/user/choice"
+ finally:
+ if saved is None:
+ os.environ.pop("QT_PLUGIN_PATH", None)
+ else:
+ os.environ["QT_PLUGIN_PATH"] = saved
+
+ system = _system_qt_version()
+ if not system:
+ print("skip qt theme guard (no system Qt6 found)")
+ return
+ assert system.count(".") == 2, system
+
+ from PySide6.QtCore import qVersion
+
+ saved = os.environ.pop("QT_PLUGIN_PATH", None)
+ try:
+ _use_system_qt_theme()
+ chosen = os.environ.get("QT_PLUGIN_PATH")
+ if system == qVersion():
+ assert chosen, "matching Qt versions should adopt system plugins"
+ assert Path(chosen, "platformthemes").is_dir()
+ else:
+ assert chosen is None, (
+ f"must not load {system} plugins into Qt {qVersion()}"
+ )
+ finally:
+ if saved is None:
+ os.environ.pop("QT_PLUGIN_PATH", None)
+ else:
+ os.environ["QT_PLUGIN_PATH"] = saved
+ print("ok system qt theme guard")
+
+
+def test_version_matches_changelog():
+ """The package version must be the newest release in the changelog."""
+ import re
+
+ import llamachat
+
+ assert re.fullmatch(r"\d+\.\d+\.\d+", llamachat.__version__), (
+ f"not semver: {llamachat.__version__}"
+ )
+
+ changelog = Path(__file__).resolve().parent / "CHANGELOG.md"
+ if not changelog.exists():
+ print("skip changelog check (file absent)")
+ return
+
+ released = re.findall(
+ r"^## \[(\d+\.\d+\.\d+)\]", changelog.read_text(), re.MULTILINE
+ )
+ assert released, "changelog has no released versions"
+ assert released[0] == llamachat.__version__, (
+ f"__version__ is {llamachat.__version__} but the newest changelog "
+ f"entry is {released[0]}"
+ )
+ print("ok version matches changelog")
+
+
+def test_venv_discovery():
+ """The launcher must find a venv even when it shares the system binary."""
+ saved = os.environ.pop("LLAMACHAT_PYTHON", None)
+ try:
+ found = llamachat_venv.find_interpreter()
+ current = Path(sys.executable)
+ other = [
+ c for c in llamachat_venv.CANDIDATES if c.is_file() and c != current
+ ]
+ if other:
+ # A venv built with --system-site-packages has a bin/python3 that
+ # symlinks to the system interpreter. Comparing resolved paths
+ # would discard it as "the interpreter we are already running".
+ assert found is not None, (
+ f"a candidate exists ({other[0]}) but none was selected"
+ )
+ assert found.is_file()
+ else:
+ # Already running as the only candidate; nothing to hand over to.
+ assert found is None
+
+ # An explicit override wins over the search order.
+ os.environ["LLAMACHAT_PYTHON"] = sys.executable
+ chosen = llamachat_venv.find_interpreter()
+ # Only rejected because it is the interpreter already running.
+ assert chosen is None or chosen == Path(sys.executable)
+
+ os.environ["LLAMACHAT_PYTHON"] = "/nonexistent/python3"
+ assert llamachat_venv.find_interpreter() is None
+ finally:
+ if saved is None:
+ os.environ.pop("LLAMACHAT_PYTHON", None)
+ else:
+ os.environ["LLAMACHAT_PYTHON"] = saved
+
+ # reexec must be a no-op once PySide6 is importable, or it would loop.
+ if llamachat_venv.have_pyside():
+ llamachat_venv.reexec(__file__) # returns rather than exec'ing
+ print("ok venv discovery")
+
+
+def test_config_defaults():
+ cfg = config.load(Path("/nonexistent/config.toml"))
+ assert cfg.base_url == "http://localhost:8181"
+ assert cfg.presets_path == Path("/etc/llama-server/presets.ini")
+ assert cfg.socket_path.name == "llamachat.sock"
+ assert cfg.db_path.name == "history.db"
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "config.toml"
+ path.write_text(
+ 'base_url = "http://localhost:9999/"\ndefault_model = "m"\n'
+ )
+ cfg = config.load(path)
+ assert cfg.base_url == "http://localhost:9999" # trailing / stripped
+ assert cfg.default_model == "m"
+ assert cfg.request_timeout == 300 # default kept
+ print("ok config defaults")
+
+
+if __name__ == "__main__":
+ test_presets()
+ test_real_presets()
+ test_fts_query_escaping()
+ test_history_roundtrip()
+ test_attachment_truncation()
+ test_classify()
+ test_sse_parsing()
+ test_reasoning_storage()
+ test_migration_adds_reasoning()
+ test_user_content()
+ test_markdown_rendering()
+ test_system_qt_theme_guard()
+ test_version_matches_changelog()
+ test_venv_discovery()
+ test_config_defaults()
+ print("\nall checks passed")