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 246be1fa8e
commit 5117168454
39 changed files with 2981 additions and 34 deletions
+300
View File
@@ -0,0 +1,300 @@
/*
The terminal panel.
Loaded only on a chat that can actually open a shell -- see the head and
scripts blocks in chat/index.html -- because xterm is nearly three times the
size of everything else vendored here. The Terminal object itself is built on
the first *open* rather than on load, so even here nothing is parsed for
somebody who never presses the button.
Three things about xterm that are easy to get wrong, and cost an afternoon
each:
* `fit()` measures `offsetWidth`, which is 0 inside a `[hidden]` ancestor, so
fitting while closed silently does nothing and leaves an 80-column terminal
in a 34rem panel. Everything below is arranged so a fit only ever happens
after the panel is visible.
* A window `resize` event does not fire when the sidebar is toggled or a panel
opens beside this one, which is by far the commonest way the panel changes
size. Hence the ResizeObserver.
* xterm does not read CSS variables. The theme is built from the computed
style at open time and rebuilt when the theme changes, or switching to
`shire` leaves a black rectangle in a light interface.
*/
(function () {
"use strict";
var panel = null;
var term = null;
var fit = null;
var socket = null;
var screen = null;
var messageEl = null;
var observer = null;
var closedOnPurpose = false;
function say(text, isError) {
if (!messageEl) return;
messageEl.textContent = text;
messageEl.classList.toggle("terminal__message--error", !!isError);
}
/* --- Theme -------------------------------------------------------------- */
function readTheme() {
var style = getComputedStyle(document.documentElement);
function token(name, fallback) {
return (style.getPropertyValue(name) || "").trim() || fallback;
}
return {
background: token("--code-bg", "#0C0F13"),
foreground: token("--ink", "#E8E2D4"),
cursor: token("--accent", "#C9A227"),
cursorAccent: token("--code-bg", "#0C0F13"),
selectionBackground: token("--accent-soft", "rgba(201, 162, 39, 0.3)")
};
}
/* --- Sizing ------------------------------------------------------------- */
function visible() {
return panel && !panel.hasAttribute("hidden") && panel.offsetWidth > 0;
}
function refit() {
if (!term || !fit || !visible()) return;
try {
fit.fit();
} catch (error) {
return;
}
send({ t: "resize", cols: term.cols, rows: term.rows });
}
/* --- The socket --------------------------------------------------------- */
function send(payload) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
function connect() {
if (socket) return;
closedOnPurpose = false;
var base = location.protocol === "https:" ? "wss://" : "ws://";
var url =
base + location.host + panel.dataset.url +
"?cols=" + (term.cols || 80) + "&rows=" + (term.rows || 24);
say("Connecting…");
socket = new WebSocket(url);
socket.binaryType = "arraybuffer";
socket.onmessage = function (event) {
if (typeof event.data === "string") return control(event.data);
/* Written straight through as bytes. xterm's decoder is stateful across
calls, so a multi-byte character split across two frames still lands
correctly -- which is exactly why the server never decodes either. */
term.write(new Uint8Array(event.data));
};
socket.onclose = function () {
socket = null;
if (!closedOnPurpose) say("Disconnected. Close and reopen to reconnect.");
};
socket.onerror = function () {
/* A failed handshake gives the page nothing: no status, no reason. So
this is a guess, and it names the likeliest cause rather than
pretending to know. */
say("Could not connect. If this instance is behind a proxy, it may not " +
"be passing WebSocket upgrades through.", true);
};
}
function control(raw) {
var payload;
try {
payload = JSON.parse(raw);
} catch (error) {
return;
}
if (payload.t === "ready") {
say(payload.shared
? "Connected. This shell is also open in another tab, and they share a size."
: "Connected.");
if (payload.dir) {
var where = panel.querySelector("[data-terminal-where]");
if (where) where.textContent = payload.dir;
}
/* The server may have opened the shell at a size chosen by whoever got
here first, so ask for ours now that there is something to ask. */
refit();
term.focus();
return;
}
if (payload.t === "behind") {
/* This window stopped reading and was disconnected so the others kept
up. Reconnecting costs nothing: the scrollback is the state. */
say(payload.message || "Reconnecting…");
closedOnPurpose = true;
if (socket) socket.close();
socket = null;
term.reset();
connect();
return;
}
if (payload.t === "closed" || payload.t === "error") {
say(payload.message || "This terminal closed.", payload.t === "error");
closedOnPurpose = true;
/* Deliberately no reconnect. A new shell has lost the working directory,
the environment and the half-typed command, and quietly substituting
one is worse than saying the connection went. */
}
}
/* --- Building it -------------------------------------------------------- */
function build() {
if (term) return true;
if (typeof Terminal === "undefined" || typeof FitAddon === "undefined") {
say("The terminal could not be loaded.", true);
return false;
}
term = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
fontFamily: getComputedStyle(document.documentElement)
.getPropertyValue("--font-mono").trim() || "monospace",
fontSize: 13,
scrollback: 5000,
theme: readTheme()
});
/* The module namespace is the UMD global, so the class is a property of
it. `new FitAddon()` is the mistake that reads correctly. */
fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(screen);
term.onData(function (data) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(new TextEncoder().encode(data));
}
});
/* Ctrl+C is interrupt here, which is correct and will still surprise
somebody. Copy and paste are the shifted pair, as in every terminal. */
term.attachCustomKeyEventHandler(function (event) {
if (!event.ctrlKey || !event.shiftKey || event.type !== "keydown") return true;
var key = event.key.toLowerCase();
if (key === "c") {
var selection = term.getSelection();
if (selection) navigator.clipboard.writeText(selection);
return false;
}
if (key === "v") {
navigator.clipboard.readText().then(function (text) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(new TextEncoder().encode(text));
}
});
return false;
}
return true;
});
/* The panel changes size when the sidebar is toggled or the window is
resized, and only the second of those fires a `resize` event. */
if (window.ResizeObserver) {
observer = new ResizeObserver(function () {
refit();
});
observer.observe(panel);
}
return true;
}
function open() {
if (!build()) return;
/* Next frame: the panel has just had `hidden` removed and has no measured
width yet, so fitting now would be the silent no-op this file exists to
avoid. */
requestAnimationFrame(function () {
refit();
connect();
term.focus();
});
}
/* --- Send to chat ------------------------------------------------------- */
/* Into the composer, never sent. What a machine printed is exactly the sort
of text somebody should read before a model does, and the box is where
that happens. */
function sendToChat() {
if (!term) return;
var text = term.getSelection();
if (!text) {
var lines = [];
var buffer = term.buffer.active;
var last = buffer.baseY + buffer.cursorY;
for (var y = Math.max(0, last - 40); y <= last; y++) {
var line = buffer.getLine(y);
if (line) lines.push(line.translateToString(true));
}
text = lines.join("\n").replace(/\n+$/, "");
}
if (!text.trim()) {
say("Nothing to send: select some output first.");
return;
}
var input = document.querySelector("[data-composer-input]");
if (!input) return;
var fence = "```\n" + text + "\n```\n";
input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + fence : fence;
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
input.focus();
/* "As it appeared" and not "as it was written": the buffer holds what is on
screen, hard-wrapped at the terminal's width, with no way to tell a wrap
from a newline. */
say("Copied into the message box as it appeared on screen.");
}
/* --- Wiring ------------------------------------------------------------- */
function start() {
panel = document.querySelector("[data-terminal]");
if (!panel) return;
screen = panel.querySelector("[data-terminal-screen]");
messageEl = panel.querySelector("[data-terminal-message]");
panel.addEventListener("lembas:toggle", function (event) {
if (event.detail && event.detail.open) open();
/* Closing leaves the Terminal object and the socket alone. `write()` is
internally queued, so disposing mid-output drops it, and keeping the
object is what makes reopening instant. The session on the far side
outlives this panel by design. */
});
panel.addEventListener("click", function (event) {
if (event.target.closest("[data-terminal-send]")) {
event.preventDefault();
sendToChat();
}
});
/* xterm holds colours as values, not as variables, so a theme change has
to be pushed into it. */
document.addEventListener("lembas:theme", function () {
if (term) term.options.theme = readTheme();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();