From 0bee36648818fc701535935a03ff03b0ee950230 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?=
Date: Sun, 2 Aug 2026 18:17:04 +0200
Subject: [PATCH] The menu that never appeared, and the reason it never did
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
composer.js built its menu lazily inside show(), and refresh() wrote
list.innerHTML before calling it. `list` is null until build() has run, so the
first `/` or `@` ever typed threw a TypeError and took the handler with it. The
menu has never appeared in any browser. That is why /compact "isn't there":
nothing was. I shipped it having only run `node --check`, which parses the file
happily.
So this also brings the thing that catches it: a DOM stub driven under node --
not committed, hard rule 1 stands, it is an instrument like curl. It reproduced
the crash in one run and immediately found two more: choosing a command from the
menu left `/help` sitting in the box so the next Enter ran it again, and Tab
completed nothing. Tab now completes and Enter runs, which is the split that
matters for a command taking an argument.
`.select--sm` was used three times and defined nowhere. I deleted the copy in
chat.css and left a comment saying it "is defined once, in app.css", where it
did not exist -- so those selects fell back to plain `.select`: width 100% in a
flex row where four siblings wanted the same, all of them shrinking together
until each was a few characters wide, and half a rem taller than everything
beside them. That was the whole of "the connection switch needs to be wider".
The connection and directory move to the topbar. They cannot change -- update_chat
refuses both with a 409 -- so they are facts about the chat, of a kind with the
Temporary badge, not controls on the message. The mode stays by the box.
Compaction says it is working. It makes a model call that takes seconds and had
no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the
Generation.status channel that says "Summarising earlier messages…" for the
automatic path cannot be borrowed, because it lives in the streaming bubble and
this endpoint refuses to run while any message is unfinished. The overflow menu
now runs the same code as /compact rather than posting for itself, so there is
one implementation, one spinner, and one place the endpoint's four carefully
written 409s finally reach somebody.
/effort, low medium high, per chat with a per-model default. It goes out twice
because there is no field that works everywhere: OpenAI and vLLM read
reasoning_effort, llama.cpp's own docs say other values "have no effect" and its
maintainer says the field "simply gets dropped without error or logging" -- what
reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once
an effort has been chosen, so a provider strict about unknown parameters sees
exactly the request it always did until somebody opts in. The control appears
only on a model marked `reasoning`, a flag that has existed since the beginning
with no reader at all.
Mentions and recognised commands are marked as you type -- a mirror behind the
textarea holding the same text with every character transparent, contributing
nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle
rather than a doubled glyph. A command is marked only when it resolves, so
`/thoughts on this` visibly is not one before you send it. And again in the
transcript, where user turns had no render step at all and now escape before
they inject.
Co-Authored-By: Claude Opus 5 (1M context)
---
CLAUDE.md | 51 +++++-
PLAN.md | 8 +-
README.md | 13 +-
pyproject.toml | 2 +-
src/lembas/__init__.py | 2 +-
src/lembas/api/admin_models.py | 14 ++
src/lembas/api/chats.py | 31 ++++
src/lembas/api/pages.py | 4 +
src/lembas/services/chat.py | 29 ++++
src/lembas/services/markdown.py | 31 ++++
src/lembas/web/static/css/app.css | 59 +++++++
src/lembas/web/static/css/chat.css | 119 ++++++++++---
src/lembas/web/static/js/app.js | 1 +
src/lembas/web/static/js/audio.js | 1 +
src/lembas/web/static/js/commands.js | 123 +++++++++++--
src/lembas/web/static/js/composer.js | 133 +++++++++++++-
src/lembas/web/static/js/terminal.js | 1 +
.../web/templates/admin/model_detail.html | 26 +++
src/lembas/web/templates/chat/_composer.html | 70 ++++++--
src/lembas/web/templates/chat/_message.html | 5 +-
src/lembas/web/templates/chat/index.html | 24 ++-
src/lembas/web/templating.py | 7 +
tests/test_effort.py | 164 ++++++++++++++++++
tests/test_highlight.py | 129 ++++++++++++++
24 files changed, 968 insertions(+), 79 deletions(-)
create mode 100644 tests/test_effort.py
create mode 100644 tests/test_highlight.py
diff --git a/CLAUDE.md b/CLAUDE.md
index 0f52e20..76bbb24 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
-pytest # 981 tests, ~56s
+pytest # 1009 tests, ~58s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -136,7 +136,7 @@ src/lembas/
templates/ Jinja
static/ css, js, vendor, img, sw.js
js/commands.js the / table, and the keyboard that does the same jobs
- js/composer.js the menu that / and @ open, over the message box
+ js/composer.js the menu / and @ open, and the mirror that marks them
assets/ SVG masters and PWA icons (generated)
deploy/ systemd unit, nginx vhost, install/update scripts
```
@@ -401,12 +401,59 @@ every name worth having. Watch the double-count — collapsing a parent subsumes
child already collapsed, and adding both savings stops the loop early believing
it has made room it has not.
+**There is no test runner for the JavaScript, so drive it under a DOM stub.**
+Hard rule 1 keeps Node out of the *project*; it does not stop using the `node`
+on this machine as a development instrument, the way `curl` is used. This is
+not a nicety. `composer.js` built its menu lazily inside `show()` while
+`refresh()` wrote to `list` before calling it — so the first `/` or `@` ever
+typed threw on a null and took the handler with it, and the menu never appeared
+in any browser for the whole life of the feature. `node --check` parses that
+file happily. A forty-line stub of `document`, `window` and `fetch` that fires
+one `input` event catches it in a second, and caught two more on the same run:
+choosing a command from the menu left `/help` sitting in the box, and Tab did
+not complete. Anything touching these files gets driven before it is committed.
+
+**Two things must be sized the same or the composer's highlighting slides off.**
+A `
+
+
+
+
+ Where new chats on this model start. Anyone can change it per chat with
+ /effort, and the control only appears on a
+ model marked Reasoning above.
+
+ Sent two ways at once, because there is no one field that works: OpenAI
+ and vLLM read reasoning_effort, while
+ llama.cpp drops it silently and reads only
+ chat_template_kwargs — which is the route by
+ which it reaches gpt-oss. Both go out, and only on a chat that has an
+ effort set, so an endpoint strict about unknown parameters is untouched
+ until somebody chooses one.
+