The menu that never appeared, and the reason it never did
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
712b7b7cab
commit
439f1a5d84
@@ -641,6 +641,7 @@
|
||||
if (!input) return;
|
||||
input.value = suggestion.dataset.suggestion;
|
||||
autosize(input);
|
||||
if (window.lembas.paintComposer) window.lembas.paintComposer();
|
||||
var form = input.closest("form");
|
||||
/* requestSubmit, not submit(): it fires the submit event, which is what
|
||||
htmx is listening for. Same call the Enter key makes. */
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
var existing = input.value.trim();
|
||||
input.value = existing ? existing + " " + text : text;
|
||||
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
||||
if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer();
|
||||
input.focus();
|
||||
input.selectionStart = input.selectionEnd = input.value.length;
|
||||
}
|
||||
|
||||
@@ -69,25 +69,14 @@
|
||||
name: "compact",
|
||||
summary: "Summarise the earlier turns so they stop costing context",
|
||||
when: function () { return !!chat() && !!el("#thread .msg"); },
|
||||
run: function () {
|
||||
window.lembas.confirm(
|
||||
"Summarise everything before the last reply? The messages stay in the " +
|
||||
"transcript; they just stop being sent to the model.",
|
||||
{ title: "Compact this chat", label: "Compact" }
|
||||
).then(function (yes) {
|
||||
if (!yes) return;
|
||||
post("/api/chats/" + chat() + "/compact")
|
||||
.then(function (r) { return r.ok ? r.text() : Promise.reject(r); })
|
||||
.then(function (html) {
|
||||
el("#thread").innerHTML = html;
|
||||
if (window.htmx) window.htmx.process(el("#thread"));
|
||||
})
|
||||
.catch(function (r) {
|
||||
if (r && r.json) r.json().then(function (body) { note(body.detail, "error"); });
|
||||
else note("Could not compact this chat.", "error");
|
||||
});
|
||||
});
|
||||
}
|
||||
run: compact
|
||||
},
|
||||
{
|
||||
name: "effort",
|
||||
summary: "How hard a reasoning model should think",
|
||||
argument: "low | medium | high",
|
||||
when: function () { return !!chat() && !!el("[data-effort]"); },
|
||||
run: function (rest) { setEffort(rest); }
|
||||
},
|
||||
{
|
||||
name: "mode",
|
||||
@@ -174,6 +163,102 @@
|
||||
return function () { window.location = url; };
|
||||
}
|
||||
|
||||
/* --- Reasoning effort ---------------------------------------------------
|
||||
The command drives the same select the composer shows, so there is one
|
||||
piece of state and the control updates itself when the command is used. */
|
||||
var EFFORTS = ["low", "medium", "high"];
|
||||
|
||||
function setEffort(rest) {
|
||||
var select = el("[data-effort]");
|
||||
if (!select) {
|
||||
return note("This model is not marked as a reasoning model.", "error");
|
||||
}
|
||||
var wanted = (rest || "").trim().toLowerCase();
|
||||
if (!wanted) {
|
||||
return note(
|
||||
select.value
|
||||
? "Effort is " + select.value + ". /effort low, medium, high, or default."
|
||||
: "Effort is whatever the model does by default. Try low, medium or high."
|
||||
);
|
||||
}
|
||||
if (wanted === "default" || wanted === "none") wanted = "";
|
||||
else if (EFFORTS.indexOf(wanted) === -1) {
|
||||
return note("“" + wanted + "” is not an effort. Try low, medium or high.", "error");
|
||||
}
|
||||
select.value = wanted;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
note(
|
||||
wanted
|
||||
? "Effort set to " + wanted + "."
|
||||
: "Effort cleared; the model decides."
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Compacting --------------------------------------------------------
|
||||
One implementation, reached from the command and from the overflow menu
|
||||
alike. The menu used to do its own hx-post, which meant two code paths and
|
||||
a spinner on neither -- so after confirming, the menu closed and nothing
|
||||
visible happened for however long the summarising model took.
|
||||
|
||||
The `Generation.status` channel that says "Summarising earlier messages…"
|
||||
during *automatic* compaction cannot be borrowed: it lives inside the
|
||||
streaming bubble, and this endpoint refuses to run while any message is
|
||||
unfinished, so there is no bubble to put it in. */
|
||||
var compacting = false;
|
||||
|
||||
function compact() {
|
||||
if (compacting) return note("Already summarising.");
|
||||
var thread = el("#thread");
|
||||
if (!thread) return;
|
||||
|
||||
window.lembas.confirm(
|
||||
"Summarise everything before the last reply? The messages stay in the " +
|
||||
"transcript; they just stop being sent to the model.",
|
||||
{ title: "Compact this chat", label: "Compact" }
|
||||
).then(function (yes) {
|
||||
if (!yes) return;
|
||||
|
||||
compacting = true;
|
||||
var working = document.createElement("div");
|
||||
working.className = "thread__working";
|
||||
// The same words the automatic path uses, so the two do not look like
|
||||
// different features.
|
||||
working.innerHTML =
|
||||
'<span class="dots"><i></i><i></i><i></i></span>' +
|
||||
"<span>Summarising the earlier messages…</span>";
|
||||
thread.appendChild(working);
|
||||
if (window.lembas.scrollThread) window.lembas.scrollThread(true);
|
||||
|
||||
function done() {
|
||||
compacting = false;
|
||||
working.remove();
|
||||
}
|
||||
|
||||
post("/api/chats/" + chat() + "/compact")
|
||||
.then(function (r) { return r.ok ? r.text() : Promise.reject(r); })
|
||||
.then(function (html) {
|
||||
compacting = false;
|
||||
// The response replaces the thread wholesale, placeholder included.
|
||||
thread.innerHTML = html;
|
||||
if (window.htmx) window.htmx.process(thread);
|
||||
if (window.lembas.scrollThread) window.lembas.scrollThread(true);
|
||||
})
|
||||
.catch(function (r) {
|
||||
done();
|
||||
/* The endpoint's four 409s are written for a person to read -- "Wait
|
||||
for the current reply to finish, then compact." -- and until now
|
||||
reached nobody at all. */
|
||||
if (r && r.json) {
|
||||
r.json()
|
||||
.then(function (body) { note(body.detail || "Could not compact.", "error"); })
|
||||
.catch(function () { note("Could not compact this chat.", "error"); });
|
||||
} else {
|
||||
note("Could not compact this chat.", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggle(selector, group) {
|
||||
var panel = el(selector);
|
||||
if (panel && window.lembas.setPanel) {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
var menu = null;
|
||||
var list = null;
|
||||
var footer = null;
|
||||
var open = false;
|
||||
var kind = "";
|
||||
var pending = null;
|
||||
@@ -105,6 +106,11 @@
|
||||
menu.hidden = true;
|
||||
list = document.createElement("div");
|
||||
menu.appendChild(list);
|
||||
/* What the keys do, in the one place somebody is looking when they need to
|
||||
know. Cheaper than a help sheet nobody opens. */
|
||||
footer = document.createElement("div");
|
||||
footer.className = "composer-menu__keys";
|
||||
menu.appendChild(footer);
|
||||
host.insertBefore(menu, host.firstChild);
|
||||
|
||||
menu.addEventListener("mousedown", function (event) {
|
||||
@@ -121,6 +127,15 @@
|
||||
function show() {
|
||||
build();
|
||||
if (!menu) return;
|
||||
if (footer) {
|
||||
// textContent: nothing here is markup, and one day somebody will want to
|
||||
// put a filename in it.
|
||||
footer.textContent = options().length
|
||||
? (kind === "/"
|
||||
? "↑↓ choose · Tab complete · Enter run · Esc dismiss"
|
||||
: "↑↓ choose · Enter attach · Esc dismiss")
|
||||
: "Esc dismiss";
|
||||
}
|
||||
menu.hidden = false;
|
||||
open = true;
|
||||
}
|
||||
@@ -161,7 +176,9 @@
|
||||
fetch(url, { credentials: "same-origin" })
|
||||
.then(function (response) { return response.text(); })
|
||||
.then(function (html) {
|
||||
if (kind !== "@") return;
|
||||
/* The reply is a round trip late: the caret may have moved off the
|
||||
mention, or onto a slash, by the time it lands. */
|
||||
if (kind !== "@" || !list) return;
|
||||
list.innerHTML = html;
|
||||
show();
|
||||
highlight(0);
|
||||
@@ -172,6 +189,14 @@
|
||||
function refresh() {
|
||||
var input = composer();
|
||||
if (!input) return;
|
||||
/* Built here, not lazily inside `show()`. It used to be, and everything
|
||||
below writes to `list` *before* calling `show()` -- so the first slash or
|
||||
at-sign ever typed threw on a null `list` and took the whole handler with
|
||||
it. The menu never appeared, in any browser, for the entire life of the
|
||||
feature. Anything that touches `list` builds first. */
|
||||
build();
|
||||
if (!list) return;
|
||||
|
||||
var token = tokenAt(input);
|
||||
if (!token) return hide();
|
||||
|
||||
@@ -203,9 +228,30 @@
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function choose(option) {
|
||||
/*
|
||||
`complete` is Tab, `!complete` is Enter, and the difference matters for a
|
||||
command that takes an argument: Tab writes `/mode ` and leaves the caret
|
||||
after it so the value can be typed, Enter runs what is there. Picking with
|
||||
the mouse runs, because there is nowhere else for a click to mean.
|
||||
*/
|
||||
function choose(option, complete) {
|
||||
if (option.dataset.command) {
|
||||
if (complete) {
|
||||
replaceToken(option.dataset.command);
|
||||
hide();
|
||||
/* Straight back, because `/mode ` is now a token with a space in it and
|
||||
the menu would otherwise stay open over the argument being typed. */
|
||||
return;
|
||||
}
|
||||
var input = composer();
|
||||
hide();
|
||||
/* Cleared before running. A command is the whole message, never part of
|
||||
one, so leaving `/help` sitting in the box after running it means the
|
||||
next Enter runs it again. */
|
||||
if (input) {
|
||||
input.value = "";
|
||||
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
||||
}
|
||||
runCommand(option.dataset.command, "");
|
||||
return;
|
||||
}
|
||||
@@ -245,6 +291,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Marking the tokens as they are typed --------------------------------
|
||||
Backgrounds only. The mirror's text is transparent and exists purely to
|
||||
put a rectangle in the right place; the visible glyphs are still the
|
||||
textarea's own. Everything here is set as textContent or built with
|
||||
createElement -- what is in the box is the one string a person controls
|
||||
exactly, and it must never become markup. */
|
||||
function mirrorEl() {
|
||||
return document.querySelector("[data-composer-mirror]");
|
||||
}
|
||||
|
||||
/* The same rule the server applies to a sent message: `@` at the start or
|
||||
after whitespace, so an email address is not a file reference. */
|
||||
var MENTION = /(^|\s)(@[^\s@]+)/g;
|
||||
|
||||
function paint() {
|
||||
var mirror = mirrorEl();
|
||||
var input = composer();
|
||||
if (!mirror || !input) return;
|
||||
|
||||
var value = input.value;
|
||||
mirror.replaceChildren();
|
||||
|
||||
/* A command is marked only when it resolves. `/thoughts on this` is not a
|
||||
command and must not look like one before it is sent -- that ambiguity
|
||||
is the whole reason `//` exists. */
|
||||
var rest = value;
|
||||
var command = commandIn(value);
|
||||
if (command) {
|
||||
var head = "/" + command.name;
|
||||
mirror.appendChild(token(head, "tok-command"));
|
||||
rest = value.slice(head.length);
|
||||
}
|
||||
|
||||
var last = 0;
|
||||
var match;
|
||||
MENTION.lastIndex = 0;
|
||||
while ((match = MENTION.exec(rest)) !== null) {
|
||||
mirror.appendChild(document.createTextNode(rest.slice(last, match.index) + match[1]));
|
||||
mirror.appendChild(token(match[2], "tok-mention"));
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
// A trailing newline collapses in a div; the space keeps the last line.
|
||||
mirror.appendChild(document.createTextNode(rest.slice(last) + "\n"));
|
||||
mirror.scrollTop = input.scrollTop;
|
||||
}
|
||||
|
||||
function token(text, cls) {
|
||||
var span = document.createElement("span");
|
||||
span.className = cls;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
/* --- Keys --------------------------------------------------------------- */
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
@@ -274,7 +373,7 @@
|
||||
item chosen. */
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
choose(all[active]);
|
||||
choose(all[active], event.key === "Tab");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -298,9 +397,31 @@
|
||||
);
|
||||
|
||||
document.addEventListener("input", function (event) {
|
||||
if (event.target.closest("[data-composer-input]")) refresh();
|
||||
if (!event.target.closest("[data-composer-input]")) return;
|
||||
refresh();
|
||||
paint();
|
||||
});
|
||||
|
||||
/* The textarea scrolls once it hits data-max-height, and the mirror has to
|
||||
go with it or the rectangles drift off the words. */
|
||||
document.addEventListener(
|
||||
"scroll",
|
||||
function (event) {
|
||||
var input = event.target;
|
||||
if (!input.closest || !input.closest("[data-composer-input]")) return;
|
||||
var mirror = mirrorEl();
|
||||
if (mirror) mirror.scrollTop = input.scrollTop;
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
/* Sending, resetting, dictating and pasting all change the value without an
|
||||
`input` event that reaches here, so the mirror is repainted after any htmx
|
||||
swap and once at load. */
|
||||
document.addEventListener("DOMContentLoaded", paint);
|
||||
document.addEventListener("htmx:afterSwap", paint);
|
||||
document.addEventListener("htmx:afterSettle", paint);
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
if (!event.target.closest(".composer-menu") &&
|
||||
!event.target.closest("[data-composer-input]")) hide();
|
||||
@@ -323,4 +444,8 @@
|
||||
|
||||
window.lembas = window.lembas || {};
|
||||
window.lembas.closeComposerMenu = hide;
|
||||
/* Anything that writes into the composer from outside -- dictation, the
|
||||
terminal's Send, a slash command that clears it -- calls this so the
|
||||
rectangles keep up. */
|
||||
window.lembas.paintComposer = paint;
|
||||
})();
|
||||
|
||||
@@ -373,6 +373,7 @@
|
||||
if (!input) return;
|
||||
input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + text : text;
|
||||
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
|
||||
if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer();
|
||||
/* Auto-send never steals focus: it fires while somebody is typing in the
|
||||
terminal, and yanking the caret out of a shell mid-command is the sort
|
||||
of thing that gets a feature switched off for good. */
|
||||
|
||||
Reference in New Issue
Block a user