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:
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user