From 325a6a7c12832d8b9fb9b67567e8a1c496dc1330 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 31 Jul 2026 10:35:56 +0200 Subject: feat: initial release of llamachat 0.1.0 A native PySide6 chat client for a local llama.cpp server in router mode. Runs as a single persistent process with a Unix-socket control channel, so a Hyprland keybind toggles the window with a socket round trip rather than a process start. The control commands do not import Qt and answer in under a tenth of a second. Features: one-shot and multi-turn chat modes, runtime model discovery from /v1/models, streaming replies rendered as markdown, collapsible reasoning for models with a thinking budget, drag-and-drop file and image attachment with context-aware truncation, and SQLite history with FTS5 search. Markdown is parsed with MarkdownNoHTML so markup in a reply is displayed rather than interpreted, and links a reply produces cannot drive the interface. Includes the Hyprland Lua snippets for autostart, keybind and window rules, and a self-check suite covering everything except the GUI. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 + CHANGELOG.md | 73 ++++ LICENSE | 338 +++++++++++++++++++ README.md | 324 ++++++++++++++++++ initial-prompt.md | 98 ++++++ llamachat.desktop | 12 + llamachat.py | 42 +++ llamachat/__init__.py | 15 + llamachat/__main__.py | 267 +++++++++++++++ llamachat/backend.py | 237 +++++++++++++ llamachat/config.py | 167 ++++++++++ llamachat/db.py | 254 ++++++++++++++ llamachat/ipc.py | 109 ++++++ llamachat/ui.py | 902 ++++++++++++++++++++++++++++++++++++++++++++++++++ llamachat_venv.py | 81 +++++ test_llamachat.py | 514 ++++++++++++++++++++++++++++ 16 files changed, 3438 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 initial-prompt.md create mode 100644 llamachat.desktop create mode 100755 llamachat.py create mode 100644 llamachat/__init__.py create mode 100644 llamachat/__main__.py create mode 100644 llamachat/backend.py create mode 100644 llamachat/config.py create mode 100644 llamachat/db.py create mode 100644 llamachat/ipc.py create mode 100644 llamachat/ui.py create mode 100644 llamachat_venv.py create mode 100755 test_llamachat.py 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., + + 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. + + + Copyright (C) + + 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 . + +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. + + , 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 ~/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 `` 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 `
` 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 `` 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. 
+
+## 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. 
+#
+# 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. 
+#
+# 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. 
+#
+# 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. 
+#
+# 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. 
+#
+# 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. 
+#
+# 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, '', '', '…', 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. 
+#
+# 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. 
+#
+# 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 
 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("")
+    start = html_text.find(">", start)
+    end = html_text.rfind("")
+    if start == -1 or end == -1:
+        return html.escape(text).replace("\n", "
") + 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
 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"")
+
+
+def _tint_pre(match: re.Match) -> str:
+    existing = (match.group(2) or "").strip()
+    if existing and not existing.endswith(";"):
+        existing += ";"
+    return f'
'
+
+
+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("", "").replace("", "").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'{arrow} {label}'
+    )
+    if not expanded:
+        return f'
{header}
' + body = html.escape(text).replace("\n", "
") + return ( + f'
{header}
' + f'
{body}
' + ) + + +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", "
") + + files = "" + if attachments: + names = ", ".join(html.escape(a.path.name) for a in attachments) + files = f"
files: {names}" + 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"
files: {', '.join(parts)}" + + 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'
{think}' + f'
{label}:
' + f"{body}{files}
" + ) + return ( + f'
{think}' + f'{label}: {body}{files}
' + ) 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. +# +# 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. +# +# 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 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 " is tinted, since Qt emits one per line of a code block. + assert fragment.count("tag l' + ) + assert "<b>" 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 "