A suggestion card sends its prompt

Filling the composer and waiting for Enter made the card a form to review
rather than a thing to press. One click, one reply.

That changes what a prompt has to be. The built-ins ended mid-sentence --
"My plan: " -- because nothing was sent until the person finished the
thought; sent cold they are a model guessing at material nobody gave it. All
three are rewritten to ask for what they need, so the first reply is the
right question instead. There is a test that they end as complete sentences,
since the failure is silent and only visible in the answer.

requestSubmit, not submit: it fires the submit event, which is what htmx
listens for. Same call the Enter key already makes.

Version bumped because app.js is what changed, and the service worker caches
it -- without the bump the first load after this would still only fill the
box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 01:12:02 +02:00
parent 584beca22d
commit d9f274ec1a
7 changed files with 45 additions and 30 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lembas" name = "lembas"
version = "0.3.0" version = "0.3.1"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.3.0" __version__ = "0.3.1"
+3 -3
View File
@@ -20,9 +20,9 @@ class Suggestion(UUIDPrimaryKey, Timestamps, Base):
name: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(String(300), default="") description: Mapped[str] = mapped_column(String(300), default="")
# What lands in the composer. Deliberately not sent on its own: it usually # Sent as the first message the moment the card is clicked, so it has to
# ends mid-sentence, because a card is a starting point rather than a # stand on its own -- there is no chance to add anything to it first. The
# question somebody already asked. # built-ins ask for what they need rather than assuming material.
prompt: Mapped[str] = mapped_column(Text, default="") prompt: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+16 -14
View File
@@ -26,31 +26,33 @@ MAX_NAME = 120
MAX_DESCRIPTION = 300 MAX_DESCRIPTION = 300
MAX_PROMPT = 4000 MAX_PROMPT = 4000
# Each ends mid-sentence, so the caret lands exactly where the person has to # A card is sent the moment it is clicked, so each of these has to work cold --
# start typing. No Middle-earth flavour: this is functional UI. # with nothing pasted and nothing typed. They are written to ask for what they
# need, which turns the first reply into the right question rather than a guess
# at material nobody has given yet.
#
# No Middle-earth flavour: this is functional UI.
DEFAULTS: tuple[tuple[str, str, str], ...] = ( DEFAULTS: tuple[tuple[str, str, str], ...] = (
( (
"Explain this", "Explain this",
"Paste something confusing and get it back in plain language.", "Get something confusing back in plain language.",
"Explain the following in plain language. Start with one sentence " "I want something explained in plain language. Ask me what it is, then "
"summarising it, then the details that actually matter, then anything I " "give me one sentence summarising it, the details that actually matter, "
"should watch out for. If I have not pasted anything yet, ask me for it " "and anything I should watch out for.",
"rather than guessing.\n\n",
), ),
( (
"Draft a reply", "Draft a reply",
"Turn a message you have received into an answer you can send.", "Turn a message you have received into an answer you can send.",
"Help me reply to the message below. If the tone I want and the outcome " "I need to reply to a message. Ask me what it says, what tone I want and "
"I am after are not obvious from it, ask me before writing. Then give me " "what outcome I am after, then write a draft I could send as it stands.",
"a draft I could send as it stands.\n\n",
), ),
( (
"Find the flaw", "Find the flaw",
"Have a plan argued with before you commit to it.", "Have a plan argued with before you commit to it.",
"I am going to describe a plan. Argue against it: what is most likely to " "I want a plan argued with. Ask me what the plan is, then tell me what is "
"go wrong, what am I assuming without evidence, and what would change " "most likely to go wrong, what I am assuming without evidence, and what "
"your mind. Do not soften it, and do not agree just because I sound " "would change your mind. Do not soften it, and do not agree just because "
"confident.\n\nMy plan: ", "I sound confident.",
), ),
) )
+9 -5
View File
@@ -384,9 +384,11 @@
return; return;
} }
/* A suggestion card fills the composer and stops there. It deliberately /* A suggestion card sends its prompt. One click, one reply -- filling the
does not submit: the prompts end mid-sentence, because a card is a box and waiting for Enter makes the card a form to review rather than a
starting point rather than a question somebody already asked. */ thing to press. The built-in prompts are written to work sent cold: each
asks for what it needs, so the answer is a question back rather than a
guess at material nobody has given yet. */
var suggestion = event.target.closest("[data-suggestion]"); var suggestion = event.target.closest("[data-suggestion]");
if (suggestion) { if (suggestion) {
event.preventDefault(); event.preventDefault();
@@ -394,8 +396,10 @@
if (!input) return; if (!input) return;
input.value = suggestion.dataset.suggestion; input.value = suggestion.dataset.suggestion;
autosize(input); autosize(input);
input.focus(); var form = input.closest("form");
input.setSelectionRange(input.value.length, input.value.length); /* requestSubmit, not submit(): it fires the submit event, which is what
htmx is listening for. Same call the Enter key makes. */
if (form) form.requestSubmit();
return; return;
} }
@@ -7,9 +7,9 @@
{% block admin_content %} {% block admin_content %}
<p class="admin-lede"> <p class="admin-lede">
Cards on the new-chat screen. Clicking one puts its prompt in the composer Cards on the new-chat screen. Clicking one sends its prompt straight away, so
without sending it — the built-in ones deliberately end mid-sentence, so the each has to work on its own — write one that asks for whatever it needs, and
caret lands where the person has to start typing. The first the first reply becomes the right question rather than a guess. The first
{{ max_shown }} enabled ones are shown, in this order. {{ max_shown }} enabled ones are shown, in this order.
</p> </p>
@@ -64,8 +64,8 @@
<textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt" <textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt"
rows="4">{{ suggestion.prompt }}</textarea> rows="4">{{ suggestion.prompt }}</textarea>
<p class="field__hint"> <p class="field__hint">
Put in the composer, not sent. Ending it mid-sentence is usually right: Sent as the first message. Nothing is added to it, so a prompt that needs
the person still has to say what they are asking about. material should ask for it.
</p> </p>
</div> </div>
@@ -104,7 +104,7 @@
<div class="field"> <div class="field">
<label class="field__label" for="new-prompt">Prompt</label> <label class="field__label" for="new-prompt">Prompt</label>
<textarea class="textarea" id="new-prompt" name="prompt" rows="4" <textarea class="textarea" id="new-prompt" name="prompt" rows="4"
placeholder="What lands in the composer when the card is clicked."></textarea> placeholder="Sent as the first message when the card is clicked."></textarea>
</div> </div>
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--primary" type="submit">Add</button> <button class="btn btn--primary" type="submit">Add</button>
+9
View File
@@ -70,6 +70,15 @@ def test_the_seed_flag_lives_in_settings(db):
assert settings_store.get(db, suggestions_service.SEEDED_KEY) is True assert settings_store.get(db, suggestions_service.SEEDED_KEY) is True
def test_every_default_prompt_stands_on_its_own():
"""A card is sent the instant it is clicked, with nothing added. One that
trails off waiting for material the person has not given is a prompt the
model has to guess at."""
for name, _description, prompt in suggestions_service.DEFAULTS:
assert prompt == prompt.strip(), f"{name} has stray whitespace"
assert prompt.endswith("."), f"{name} does not end as a complete sentence"
# --- What is shown ------------------------------------------------------------ # --- What is shown ------------------------------------------------------------
def test_only_enabled_ones_are_shown(db): def test_only_enabled_ones_are_shown(db):
suggestions_service.create(db, name="Shown", prompt="a") suggestions_service.create(db, name="Shown", prompt="a")