The terminal learns where one command ends, and can be dragged wider

"The last command and its output" was not something the panel could honestly
offer. sendToChat took the last forty rows of the screen buffer, hard-wrapped at
the terminal's width with no way to tell a wrap from a newline -- its own comment
said so. So bash and zsh are given the OSC 133 markers VS Code and WezTerm use,
and Copy, Send and an Auto toggle are built on those.

The integration is written by the PTY command string itself, with printf. sshd
runs that string through $SHELL -c, so it can case on the shell's own name and
needs no probe, no second channel and no writable home. Passing it through the
environment does not work -- every distribution ships AcceptEnv LANG LC_*, so
anything else is dropped silently -- and feeding `source ...` in as keystrokes
races a slow .zshrc, echoes into the scrollback and lands in shell history.

Nothing needs hiding, which is the point of choosing it: the setup runs before
the shell exists and never writes to the PTY's input side, so there is nothing
to echo and no fan-out gate to build.

Two things were wrong in the first version and both were found by running it
against real shells rather than the fake one. bash: the DEBUG trap fires before
every simple command *including each one inside PROMPT_COMMAND*, so $? read from
there is whatever ran a moment ago -- every command reported success. The status
is captured in the trap now, which also removes the two-entry PROMPT_COMMAND
dance entirely. zsh: $ZDOTDIR is already ours by the time .zshenv runs, so the
shims were sourcing themselves and none of the user's configuration loaded; the
original is passed on the exec line.

Parsing is server-side. The `behind` path resets the terminal and replays a
truncated scrollback, so a client parser routinely sees a finish with no start;
two tabs share one shell and can disagree; and what comes out of this ends up
inside a prompt, so deriving it here leaves nothing to disbelieve. The bytes are
fanned out unchanged -- xterm consumes an OSC it has no handler for.

Output is bounded head and tail, 48KB and 16KB: a build that fails ten megabytes
in has the invocation at the top and the error at the bottom. Carriage returns
collapse to the last state of each line, which is the difference between a
usable prompt and two megabytes of spinner. The fence is sized to its content,
because output containing three backticks would otherwise break out and read as
prose.

Any shell that is not bash or zsh starts exactly as it did before. The buttons
then scrape the screen and say so, and Auto is disabled rather than degraded:
forty arbitrary lines on every message is worse than nothing.

Also a generic [data-resize] handle, keyboard included, persisted the way the
theme is. The inspector and sidebar can have it whenever they want it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 17:28:38 +02:00
parent fc02eb5538
commit 6bbd398707
17 changed files with 1734 additions and 43 deletions
+112
View File
@@ -493,6 +493,113 @@
);
}
/* --- Dragging a panel wider ---------------------------------------------
Generic rather than terminal-specific: the inspector and the sidebar want
the same handle, and a second copy of this is how two panels end up
resizing differently.
The width lands on a CSS variable on <html> rather than on the panel, so
the ≤64rem overlay rule -- which clamps it with min() -- keeps working
without knowing anything about dragging. Persisted the way the theme is:
localStorage for this tab, best-effort POST for the next device. */
var RESIZE_KEY = "lembas-panel-widths";
function storedWidths() {
try {
return JSON.parse(localStorage.getItem(RESIZE_KEY) || "{}") || {};
} catch (e) {
return {};
}
}
function applyWidths() {
var widths = storedWidths();
Object.keys(widths).forEach(function (name) {
document.documentElement.style.setProperty(name, widths[name] + "px");
});
}
function rememberWidth(name, pixels) {
var widths = storedWidths();
widths[name] = Math.round(pixels);
try {
localStorage.setItem(RESIZE_KEY, JSON.stringify(widths));
} catch (e) { /* private mode */ }
if (document.body.dataset.authenticated === "true") {
fetch("/api/preferences/layout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(widths)
}).catch(function () { /* already applied locally */ });
}
}
function setupResize() {
document.addEventListener("pointerdown", function (event) {
var handle = event.target.closest("[data-resize]");
if (!handle || event.button !== 0) return;
var panel = handle.closest("[data-resize-target]") || handle.parentElement;
if (!panel) return;
var name = handle.dataset.resize;
var min = parseFloat(handle.dataset.resizeMin || "320");
var startX = event.clientX;
var startWidth = panel.getBoundingClientRect().width;
var frame = null;
var pending = startWidth;
event.preventDefault();
handle.setPointerCapture(event.pointerId);
document.body.classList.add("is-resizing");
function move(moveEvent) {
/* The handle is on the panel's *left* edge and the panel is on the
right of the shell, so dragging left makes it wider. */
var max = Math.max(min, window.innerWidth - 360);
pending = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), min), max);
/* Coalesced to a frame: the ResizeObserver on the panel calls xterm's
fit() and sends a resize frame up the socket, and doing that once
per pointermove is a frame per pixel of drag. */
if (frame) return;
frame = requestAnimationFrame(function () {
frame = null;
document.documentElement.style.setProperty(name, pending + "px");
});
}
function stop() {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
handle.removeEventListener("pointercancel", stop);
document.body.classList.remove("is-resizing");
if (frame) cancelAnimationFrame(frame);
document.documentElement.style.setProperty(name, pending + "px");
rememberWidth(name, pending);
}
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
});
/* A keyboard has to be able to do this too, or the panel is only resizable
with a mouse and the handle is a focus trap that does nothing. */
document.addEventListener("keydown", function (event) {
var handle = event.target.closest("[data-resize]");
if (!handle) return;
var step = event.key === "ArrowLeft" ? 32 : event.key === "ArrowRight" ? -32 : 0;
if (!step) return;
event.preventDefault();
var panel = handle.closest("[data-resize-target]") || handle.parentElement;
var name = handle.dataset.resize;
var min = parseFloat(handle.dataset.resizeMin || "320");
var max = Math.max(min, window.innerWidth - 360);
var width = Math.min(Math.max(panel.getBoundingClientRect().width + step, min), max);
document.documentElement.style.setProperty(name, width + "px");
rememberWidth(name, width);
});
}
window.lembas = {
setPanel: setPanel,
applyTheme: applyTheme,
@@ -590,8 +697,13 @@
scrollThread(true);
applyTheme(currentTheme());
setupDropzone();
setupResize();
});
/* Before first paint rather than on DOMContentLoaded, so a panel that was
dragged wider does not open at its default and jump. */
applyWidths();
/* After any htmx swap: re-measure the composer and follow new content. */
document.body.addEventListener("htmx:afterSwap", function () {
document.querySelectorAll("[data-autosize]").forEach(autosize);