a63723713f
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>
494 lines
18 KiB
JavaScript
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();
|
|
}
|
|
})();
|