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 `
+
+
Default reasoning effort
+
+ Whatever the model does
+ {% for value in efforts %}
+
+ {{ value }}
+
+ {% endfor %}
+
+
+ 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.
+
+
+
Description
{% endif %}
-
+ {#
+ The text, with a mirror behind it.
+
+ A textarea cannot style its own contents, so the mirror holds the same
+ text with every character transparent and contributes nothing but a
+ rounded rectangle behind each recognised token. The real text stays in
+ the textarea, where it is native and selectable -- the other way round,
+ showing the mirror's text and hiding the textarea's, means any style
+ drift renders as doubled or blurred glyphs instead of a rectangle a
+ pixel out of place.
+
+ composer.js fills it. Without JavaScript there is simply no mirror.
+ #}
+
{% elif chat and chat.kind == "agent" %}
-
-
- {{ icon("bolt", "icon--sm") }}
- {{ agent_profile.name if agent_profile else "connection missing" }}
- {{ chat.project_dir }}
-
+ {# Only the mode. The connection and the directory moved to the topbar:
+ they cannot change -- update_chat refuses both with a 409 -- so they
+ are facts about the chat rather than controls on the message, and
+ they were taking a slot in a row that has work to do.
- {# Its own form: nesting one inside the composer's form is invalid
- HTML, and the browser drops the inner one. #}
+ Its own form: nesting one inside the composer's form is invalid HTML
+ and the browser drops the inner one. #}
+
{% for value, label, hint in agent_modes %}
@@ -211,6 +227,30 @@
{% endif %}
+ {#
+ How hard a reasoning model should think. Outside the agent branch
+ above, because it applies to any chat.
+
+ Only on a model an administrator has marked as **reasoning**: that
+ flag has existed since the beginning with no reader at all, and
+ offering the control everywhere would be offering a setting that does
+ nothing almost everywhere. Its own form, for the reason the mode has
+ one -- a form cannot nest inside another.
+ #}
+ {% if chat and current_model and current_model.capabilities_json.get("reasoning") %}
+
+ Effort: default
+ {% for value in efforts %}
+
+ Effort: {{ value }}
+
+ {% endfor %}
+
+ {% endif %}
+
{% if can_dictate %}
{# Recording is started and stopped by the same button; audio.js swaps
@@ -254,6 +294,10 @@
{% endif %}
+ {% if chat %}
+
+ {% endif %}
Enter to send, Shift+Enter for a new line.
diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html
index 8cb5f5c..6f68c3c 100644
--- a/src/lembas/web/templates/chat/_message.html
+++ b/src/lembas/web/templates/chat/_message.html
@@ -187,7 +187,10 @@
{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.
{% endif %}
{% elif message.content %}
-
{{ message.content }}
+ {# `tokens` escapes and then marks up: @mentions read as references
+ rather than as punctuation. It must stay `pre-wrap` -- the newlines
+ are still carried by CSS, not by markup. #}
+
{{ message.content|tokens|safe }}
{% endif %}
{# An attachment-only turn has no text; rendering the bubble anyway would
leave an empty box under the file. #}
diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html
index 8ed458e..cd93e30 100644
--- a/src/lembas/web/templates/chat/index.html
+++ b/src/lembas/web/templates/chat/index.html
@@ -35,6 +35,21 @@
{% endif %}
+ {# Where this chat runs. Beside the title because it describes the chat
+ and cannot be changed -- update_chat refuses the connection and the
+ directory with a 409 -- so it is of a kind with the Temporary badge
+ rather than with the controls on the right. The mode is the one thing
+ here that moves, and it stays down by the message box. #}
+ {% if chat and chat.kind == "agent" %}
+
+ {{ icon("bolt", "icon--sm") }}
+
+ {{ agent_profile.name if agent_profile else "connection missing" }}
+
+ {{ chat.project_dir }}
+
+ {% endif %}
+
{#
A link, not a script: the flag lives in the URL, so it survives a
@@ -118,12 +133,11 @@
{% endif %}
{% if messages %}
+ {# Runs the same code as /compact rather than posting itself. Two
+ implementations meant a spinner on neither and the endpoint's
+ error messages reaching nobody. #}
+ onclick="window.lembasCommands && window.lembasCommands.run('compact')">
{{ icon("archive", "icon--sm") }}
Compact
diff --git a/src/lembas/web/templating.py b/src/lembas/web/templating.py
index 8f8b118..e9ce371 100644
--- a/src/lembas/web/templating.py
+++ b/src/lembas/web/templating.py
@@ -12,6 +12,7 @@ from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
from lembas.services import metrics as metrics_service
+from lembas.services.markdown import highlight_tokens
from lembas.services.reasoning import format_duration
TEMPLATE_DIR = Path(__file__).parent / "templates"
@@ -44,6 +45,12 @@ def stable_hue(value: str) -> int:
templates.env.filters["stable_hue"] = stable_hue
+# A user's own message: escaped here and marked up, so `@mentions` read as
+# references rather than as punctuation. A filter rather than a context value
+# because the message templates are included from four different handlers and
+# every one of them would otherwise have to remember to pass it.
+templates.env.filters["tokens"] = highlight_tokens
+
def resolve_theme(user: User | None) -> str:
"""Theme to render with on the server.
diff --git a/tests/test_effort.py b/tests/test_effort.py
new file mode 100644
index 0000000..9444995
--- /dev/null
+++ b/tests/test_effort.py
@@ -0,0 +1,164 @@
+"""Reasoning effort: what goes out, and what does not.
+
+The second half matters as much as the first. There is no field that works
+everywhere -- OpenAI and vLLM read `reasoning_effort`, llama.cpp drops it
+silently and reads only `chat_template_kwargs` -- so both are sent. That is only
+safe because neither is sent at all until somebody chooses an effort, which is
+what keeps an endpoint strict about unknown parameters working exactly as it
+did.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+
+from lembas.db.models import Chat, Connection, Model, User
+from lembas.services import chat as chat_service
+
+
+def _model(db, **capabilities) -> Model:
+ connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
+ db.add(connection)
+ db.commit()
+ model = Model(
+ connection_id=connection.id,
+ model_id="m",
+ capabilities_json={"reasoning": True, **capabilities},
+ )
+ db.add(model)
+ db.commit()
+ return model
+
+
+def _chat(db, effort: str | None = None) -> Chat:
+ model = _model(db)
+ user = db.scalars(select(User)).first()
+ chat = Chat(
+ user_id=user.id,
+ model_id=model.model_id,
+ connection_id=model.connection_id,
+ params_json={"reasoning_effort": effort} if effort is not None else {},
+ )
+ db.add(chat)
+ db.commit()
+ return chat
+
+
+# --- What reaches the endpoint -----------------------------------------------
+def test_an_effort_goes_out_in_both_forms(client: TestClient, db, registered):
+ """One value, two fields. Neither endpoint family reads the other's."""
+ chat = _chat(db, "high")
+
+ body = chat_service.build_request(db, chat)
+
+ assert body["reasoning_effort"] == "high"
+ assert body["chat_template_kwargs"] == {"reasoning_effort": "high"}
+
+
+def test_no_effort_means_neither_field(client: TestClient, db, registered):
+ """The whole safety of sending both. A chat nobody has set an effort on is
+ byte-for-byte the request it was before this existed, so a provider that
+ refuses unknown parameters is untouched until somebody opts in."""
+ chat = _chat(db)
+
+ body = chat_service.build_request(db, chat)
+
+ assert "reasoning_effort" not in body
+ assert "chat_template_kwargs" not in body
+
+
+def test_a_cleared_effort_means_neither_field(client: TestClient, db, registered):
+ """Cleared is stored as None, like every other parameter here."""
+ chat = _chat(db, None)
+
+ body = chat_service.build_request(db, chat)
+
+ assert "reasoning_effort" not in body
+
+
+@pytest.mark.parametrize("junk", ["sudo", "HIGH ", "maximum", "1"])
+def test_a_value_that_is_not_an_effort_is_not_sent(client: TestClient, db, registered, junk):
+ """Never trusted from the row: it could predate a change to the list."""
+ chat = _chat(db, junk)
+
+ assert "reasoning_effort" not in chat_service.build_request(db, chat)
+
+
+def test_existing_chat_template_kwargs_are_kept(client: TestClient, db, registered):
+ """Merged rather than replaced, so a future caller setting something else
+ there does not lose it."""
+ body: dict = {"chat_template_kwargs": {"enable_thinking": True}}
+
+ chat_service.apply_effort(body, "low")
+
+ assert body["chat_template_kwargs"] == {"enable_thinking": True, "reasoning_effort": "low"}
+
+
+# --- Setting it ---------------------------------------------------------------
+def test_patching_the_effort_stores_it(client: TestClient, db, registered):
+ chat = _chat(db)
+
+ assert client.patch(
+ f"/api/chats/{chat.id}", data={"reasoning_effort": "medium"}
+ ).status_code == 204
+
+ db.refresh(chat)
+ assert chat.params_json["reasoning_effort"] == "medium"
+
+
+def test_an_empty_effort_clears_it(client: TestClient, db, registered):
+ chat = _chat(db, "high")
+
+ client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": ""})
+
+ db.refresh(chat)
+ assert chat.params_json["reasoning_effort"] is None
+
+
+def test_an_unknown_effort_leaves_the_old_one(client: TestClient, db, registered):
+ """Ignored, not refused: a typo should not cost the setting you had."""
+ chat = _chat(db, "low")
+
+ client.patch(f"/api/chats/{chat.id}", data={"reasoning_effort": "extreme"})
+
+ db.refresh(chat)
+ assert chat.params_json["reasoning_effort"] == "low"
+
+
+def test_the_control_only_appears_on_a_reasoning_model(client: TestClient, db, registered):
+ """The flag has existed with no reader since the beginning; this is its
+ first job. Offering the control everywhere would offer a setting that does
+ nothing almost everywhere."""
+ chat = _chat(db)
+ assert "data-effort" in client.get(f"/chat/{chat.id}").text
+
+ model = db.scalars(select(Model)).one()
+ model.capabilities_json = {"reasoning": False}
+ db.commit()
+
+ assert "data-effort" not in client.get(f"/chat/{chat.id}").text
+
+
+# --- The per-model default ----------------------------------------------------
+def test_a_new_chat_starts_from_the_models_defaults(client: TestClient, db, registered):
+ """`Model.params_json` has claimed to do this since it was added and did it
+ nowhere. It is empty on every existing row, so honouring it changes nothing
+ until an administrator sets something."""
+ model = _model(db)
+ model.params_json = {"reasoning_effort": "high"}
+ db.commit()
+
+ client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
+
+ chat = db.scalars(select(Chat)).one()
+ assert chat.params_json["reasoning_effort"] == "high"
+
+
+def test_a_model_with_no_defaults_starts_a_plain_chat(client: TestClient, db, registered):
+ _model(db)
+
+ client.post("/api/chats/start", data={"content": "hello", "model_id": "m"})
+
+ assert db.scalars(select(Chat)).one().params_json == {}
diff --git a/tests/test_highlight.py b/tests/test_highlight.py
new file mode 100644
index 0000000..b68bb26
--- /dev/null
+++ b/tests/test_highlight.py
@@ -0,0 +1,129 @@
+"""Marking `@mentions` in a message somebody wrote.
+
+This is the one render path where a person controls the bytes exactly, and
+until now there was no render path at all -- the template printed the column
+and let `white-space: pre-wrap` carry the newlines. So the first half of every
+test here is that escaping still happens, and happens *before* anything is
+injected.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+from lembas.services.markdown import highlight_tokens
+
+
+def test_a_mention_is_marked():
+ assert highlight_tokens("look at @src/main.py") == (
+ 'look at @src/main.py '
+ )
+
+
+def test_a_mention_at_the_start_is_marked():
+ assert highlight_tokens("@README.md is wrong") == (
+ '@README.md is wrong'
+ )
+
+
+def test_an_email_address_is_not_a_mention():
+ """The whole reason the pattern is anchored on whitespace. Without it every
+ address in a message becomes a highlighted file reference."""
+ assert "tok-mention" not in highlight_tokens("write to frodo@shire.test")
+
+
+def test_a_bare_at_is_left_alone():
+ assert "tok-mention" not in highlight_tokens("dinner @ 8")
+
+
+def test_several_mentions_are_all_marked():
+ marked = highlight_tokens("@a.py and @b.py")
+
+ assert marked.count("tok-mention") == 2
+
+
+@pytest.mark.parametrize(
+ "hostile",
+ [
+ "",
+ "@",
+ " ",
+ "@a\">",
+ ],
+)
+def test_markup_is_escaped_before_anything_is_injected(hostile):
+ """The order is the security property. Injecting first and escaping after
+ would escape our own span; escaping first means the span is the only markup
+ that can exist in the output."""
+ out = highlight_tokens(hostile)
+
+ assert "