A terminal panel beside an agent chat

A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.

The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.

The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 01:44:07 +02:00
parent a1824681ae
commit 47791a88c7
37 changed files with 2889 additions and 31 deletions
+48 -4
View File
@@ -41,6 +41,12 @@
: "Switch to Moria (dark)");
});
/* For anything holding colours as values rather than reading them from a
variable. The terminal is the only such thing: xterm copies its palette
at construction, so switching to Shire would otherwise leave a black
rectangle in a light interface. */
document.dispatchEvent(new CustomEvent("lembas:theme", { detail: { theme: name } }));
if (document.body.dataset.authenticated === "true") {
fetch("/api/preferences/theme", {
method: "POST",
@@ -357,7 +363,48 @@
revealInstall(false);
});
/* --- Panels ------------------------------------------------------------- */
/* A panel can be opened or closed by more than one control -- the button in
the topbar and the panel's own Close -- and it can now also be closed by
something nobody clicked, because two panels sharing the right-hand side
of the screen must not both be open. So the state is applied to the panel
and then *every* toggle pointing at it is brought in line. Setting
aria-expanded on the clicked button alone left the other one lying. */
function syncToggles(selector, open) {
var toggles = document.querySelectorAll('[data-toggle="' + selector + '"]');
for (var i = 0; i < toggles.length; i++) {
toggles[i].setAttribute("aria-expanded", open ? "true" : "false");
toggles[i].classList.toggle("is-active", open);
}
}
function setPanel(selector, open, group) {
var panel = document.querySelector(selector);
if (!panel) return;
/* One at a time down the right-hand side. Not only a narrow-screen
concern: a 1280px window with the sidebar, the inspector and the
terminal all open leaves the conversation about seventy pixels wide. */
if (open && group) {
var others = document.querySelectorAll('[data-toggle-group="' + group + '"]');
for (var i = 0; i < others.length; i++) {
var other = others[i].dataset.toggle;
if (other && other !== selector) setPanel(other, false);
}
}
panel.toggleAttribute("hidden", !open);
syncToggles(selector, open);
/* What a panel needs to know it is visible. The terminal listens for this:
xterm cannot measure itself inside a hidden element, so it has to be
told rather than left to discover. */
panel.dispatchEvent(
new CustomEvent("lembas:toggle", { bubbles: true, detail: { open: open } })
);
}
window.lembas = {
setPanel: setPanel,
applyTheme: applyTheme,
toggleTheme: toggleTheme,
copyText: copyText,
@@ -410,10 +457,7 @@
event.preventDefault();
var panel = document.querySelector(toggle.dataset.toggle);
if (!panel) return;
var nowOpen = panel.hasAttribute("hidden");
panel.toggleAttribute("hidden");
toggle.setAttribute("aria-expanded", nowOpen ? "true" : "false");
toggle.classList.toggle("is-active", nowOpen);
setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup);
}
});