Files
LLeMbas/src/lembas/web/static/js/terminal.js
T
Jaroslav Beneš a63723713f Look around the machine before deciding to talk about it
The terminal and the canvas both needed a Chat, so they were missing from the
one screen where you are choosing which machine to work on. A draft is the
smallest thing that fixes it: an id, and the three facts behind it.

The trick is that a draft resolves to a *transient* Chat -- constructed, never
added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent`
and `agent_session.resolve` read exactly four attributes between them and none
of them queries or writes the row, so all of it works unchanged and nothing had
to learn what a draft is. Proven against a real sshd rather than a stub: a
transient chat opens and saves a project file over the same SFTP path a real one
uses, and the database stays empty throughout.

Chats are still created lazily. A draft is not a chat and never becomes one;
when the first prompt makes the real one, the shell is re-keyed into it and the
open tabs are copied across. `terminal.rekey` moves the registry key *and*
`session.chat_id`, because close_for_profile, close_for_owner and the reaper all
pop by the field -- a stale one would leave a dead session that `get` keeps
handing out. The shell is only adopted when its profile and directory match the
chat as finally resolved, since `_new_chat` settles an empty directory to the
connection's own; otherwise it is left alone rather than transplanted onto a
chat that says it runs elsewhere.

Two canvas sources are refused on a draft, by name, and one of them is a hole
rather than an inconvenience. `_load_file` authorises with
`attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
stored with `chat_id=None` -- so a draft whose chat carried no id would make that
comparison `None != None`, which is False, and open every unclaimed attachment
its owner has. `as_chat` does set an id, so it already fails; the refusal is
stated anyway, because a guarantee that lives in an id-shaped coincidence is one
the next change breaks without noticing.

Adoption needed almost no JavaScript: start_chat already answers with
HX-Redirect, so the page reloads and the canvas adopts by construction while the
terminal reconnects to the re-keyed session and replays its scrollback -- the "a
reload is indistinguishable from a second tab" property working for us. What
re-points them mid-screen is a `lembas:agent-target` event, dispatched from
`setDir` and the connection select because assigning to a hidden field's value
fires nothing on its own. Driven under a DOM stub before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:33:34 +02:00

494 lines
18 KiB
JavaScript

/*
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;
/* Whether this shell tells us where commands begin and end -- "live",
"loading" or "none". Everything the three buttons do keys off it. */
var integration = "loading";
/* "off" | "copy" | "send". Three states rather than a boolean, because the
old one did the wrong one of them: it appended into the composer, on top of
whatever was being typed there. A select rather than a cycling button --
a button cannot say which of three states it is in. */
var autoMode = "off";
var lastCommand = null;
function say(text, isError) {
if (!messageEl) return;
messageEl.textContent = text;
messageEl.classList.toggle("terminal__message--error", !!isError);
}
/* --- Theme -------------------------------------------------------------- */
/* The sixteen ANSI slots, in the order xterm names them. A shell chooses
these itself -- `ls --color`, a git diff, htop's meters -- so without them
the panel rendered a foreign palette inside the application, and switching
to Shire left dark-theme colours on parchment. */
var ANSI = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"brightBlack", "brightRed", "brightGreen", "brightYellow",
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"
];
function readTheme() {
var style = getComputedStyle(document.documentElement);
function token(name, fallback) {
return (style.getPropertyValue(name) || "").trim() || fallback;
}
var theme = {
background: token("--code-bg", "#0C0F13"),
foreground: token("--ink", "#E4E8EC"),
cursor: token("--accent", "#8FB3CC"),
cursorAccent: token("--code-bg", "#0C0F13"),
selectionBackground: token("--accent-soft", "rgba(143, 179, 204, 0.3)")
};
/* camelCase to --kebab-case: brightBlack -> --ansi-bright-black. A slot
with no token is left off the object entirely rather than set to
undefined, which xterm treats as a colour and renders as black. */
ANSI.forEach(function (name) {
var value = token(
"--ansi-" + name.replace(/[A-Z]/g, function (c) { return "-" + c.toLowerCase(); }),
""
);
if (value) theme[name] = value;
});
return theme;
}
/* --- 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;
}
integration = payload.integration || "none";
showLast(payload.last);
applyIntegration();
/* 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 === "command") {
/* A command finished. Tens of bytes, not the output: a 64KB text frame
would compete with PTY bytes on the one path that has to stay quick,
and the buttons fetch what they need when they are pressed. */
if (integration !== "live") { integration = "live"; applyIntegration(); }
showLast(payload.command);
if (autoMode !== "off") {
capture(true).then(function (text) {
if (!text) return;
if (autoMode === "copy") return intoComposer(text, true);
sendStraightToChat(text);
});
}
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;
}
var style = getComputedStyle(document.documentElement);
term = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
/* xterm wants a number, so the token has to be a plain pixel value and
is parsed back out here. Reading it rather than repeating 13 is what
keeps it adjustable in one place with everything else. */
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 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();
});
}
/* --- Handing a command to the chat --------------------------------------
Three buttons over one idea: the last command, its output, and where it
ran. The server renders the block, so the text a model eventually reads
exists in exactly one place -- and the screen buffer could not produce it
anyway, holding as it does what is *on screen*, hard-wrapped at the
terminal's width with no way to tell a wrap from a newline. */
function applyIntegration() {
if (!panel) return;
var auto = panel.querySelector("[data-terminal-auto]");
if (!auto) return;
var usable = integration === "live";
auto.disabled = !usable;
auto.title = usable
? "What to do with each command you run"
: "This shell did not load LLeMbas's command markers, so there is no way " +
"to tell where one command's output ends.";
if (!usable && autoMode !== "off") setAuto("off");
}
function showLast(command) {
lastCommand = command || null;
var slot = panel && panel.querySelector("[data-terminal-last]");
if (!slot) return;
// textContent, always: this is a command line off somebody's machine.
slot.textContent = lastCommand ? lastCommand.summary : "";
}
var AUTO_SAID = {
off: "Commands are no longer attached automatically.",
copy: "Every command you run will be put into the message box.",
send: "Every command you run will be sent as a message on its own."
};
function setAuto(mode) {
autoMode = AUTO_SAID[mode] ? mode : "off";
var select = panel && panel.querySelector("[data-terminal-auto]");
if (select && select.value !== autoMode) select.value = autoMode;
say(AUTO_SAID[autoMode]);
}
/* Sent, not typed. The composer is left entirely alone -- somebody may be
half-way through a sentence in it, and overwriting that is the complaint
this replaces. The thread receives whatever the server decides the message
is: a streaming pair, or a single queued bubble if a reply is already being
written. Nothing here needs to know which. */
function sendStraightToChat(text) {
var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
if (!window.htmx) return;
window.htmx.ajax("POST", url, {
target: "#thread",
swap: "beforeend",
values: { content: text }
});
}
/* A selection always wins, in every state. People rely on it, and it is the
only way to send part of something. */
function selected() {
var text = term ? term.getSelection() : "";
return text && text.trim() ? text : "";
}
function scraped() {
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));
}
return lines.join("\n").replace(/\n+$/, "");
}
/* Fetches the rendered block, or falls back to the screen. `quiet` is the
auto path, which must not narrate every command it collects. */
function capture(quiet) {
var chosen = selected();
if (chosen && !quiet) return Promise.resolve("```\n" + chosen + "\n```\n");
if (integration !== "live") {
if (quiet) return Promise.resolve("");
var text = scraped();
if (!text.trim()) {
say("Nothing to send: select some output first.");
return Promise.resolve("");
}
/* Said plainly rather than dressed up. Without markers this is the last
forty rows as they appeared, wraps and all, and pretending otherwise
would put a precise-looking block in front of a model that is not. */
say("Copied the last of the screen, as it appeared. This shell does not " +
"mark where commands begin.");
return Promise.resolve("```\n" + text + "\n```\n");
}
return fetch(panel.dataset.url.replace(/\/ws$/, "/last"), { credentials: "same-origin" })
.then(function (response) { return response.json(); })
.then(function (body) {
if (!body.ok) {
if (!quiet) say(body.message || "Nothing to send yet.");
return "";
}
return body.text + "\n";
})
.catch(function () {
if (!quiet) say("Could not read the last command.", true);
return "";
});
}
function intoComposer(text, quiet) {
if (!text) return;
var input = document.querySelector("[data-composer-input]");
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. */
if (!quiet) input.focus();
}
function sendToChat() {
if (!term) return;
capture(false).then(function (text) {
if (!text) return;
intoComposer(text, false);
/* 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. */
if (integration === "live") say("Put into the message box. It is not sent yet.");
});
}
function copyToClipboard() {
if (!term) return;
capture(false).then(function (text) {
if (!text) return;
if (window.lembas && window.lembas.copyText) {
window.lembas.copyText(text);
say("Copied.");
}
});
}
/* --- 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();
return sendToChat();
}
if (event.target.closest("[data-terminal-copy]")) {
event.preventDefault();
return copyToClipboard();
}
});
panel.addEventListener("change", function (event) {
var select = event.target.closest("[data-terminal-auto]");
if (select) setAuto(select.value);
});
/* 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();
});
/* The panel now belongs to a different shell: the new-chat screen changed
connection or directory, so `dataset.url` has been rewritten and the
socket is pointed at the wrong machine. Closing is deliberate -- hence
the flag, which is what stops "Disconnected" being reported for something
nobody lost -- and the screen is cleared because this really is a
different shell, unlike a reconnect to the same one.
Reconnecting is left to the panel being opened, so a target changed while
the terminal is shut costs nothing. */
window.lembas = window.lembas || {};
window.lembas.repointTerminal = function () {
if (socket) {
closedOnPurpose = true;
socket.close();
socket = null;
}
if (term) term.reset();
if (panel && !panel.hidden && panel.dataset.url) connect();
};
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();