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 b6cea42631
commit 131a4083f8
19 changed files with 1854 additions and 54 deletions
+56
View File
@@ -544,14 +544,57 @@ button, input, textarea, select {
`hidden` attribute. */
.terminal {
width: var(--terminal-width);
min-width: var(--terminal-width-min);
max-width: 80vw;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
/* So the resize handle can sit on the edge. */
position: relative;
background: var(--bg-sunken);
border-left: 1px solid var(--border);
}
/* The drag handle on a panel's left edge. Wider than it looks -- a one-pixel
border is a target nobody can hit -- and it sits *outside* the panel's own
padding so it never overlaps what is being resized. */
.panel-resize {
position: absolute;
top: 0;
bottom: 0;
left: -3px;
width: 9px;
z-index: var(--z-handle);
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
color: transparent;
touch-action: none;
transition: background var(--transition-fast), color var(--transition-fast);
}
.panel-resize:hover,
.panel-resize:focus-visible,
body.is-resizing .panel-resize {
background: var(--accent-soft);
color: var(--accent);
outline: none;
}
/* While dragging, nothing else may take the pointer -- a text selection
starting mid-drag makes the whole page flicker blue, and the iframe-shaped
hazard is the terminal itself swallowing pointermove. */
body.is-resizing {
cursor: col-resize;
user-select: none;
}
body.is-resizing .terminal__screen { pointer-events: none; }
@media (max-width: 64rem) {
/* A full-height overlay has no edge to drag, and no room to spare. */
.panel-resize { display: none; }
}
.terminal__where {
font-weight: 400;
font-family: var(--font-mono);
@@ -586,6 +629,19 @@ button, input, textarea, select {
}
.terminal__status strong { color: var(--ink-muted); font-weight: 600; }
.terminal__message { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* What the last command was. Monospace and clipped: it is a command line, and
one long enough to wrap would push the status bar into two rows. */
.terminal__last {
flex: 0 1 auto;
min-width: 0;
max-width: 16rem;
font-family: var(--font-mono);
color: var(--ink-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal__last:empty { display: none; }
.terminal__message--error { color: var(--danger); }
@media (max-width: 64rem) {
+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);
+150 -27
View File
@@ -32,6 +32,11 @@
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";
var autoSend = false;
var lastCommand = null;
function say(text, isError) {
if (!messageEl) return;
@@ -148,6 +153,9 @@
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();
@@ -155,6 +163,18 @@
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 (autoSend) {
capture(true).then(function (text) { intoComposer(text, true); });
}
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. */
@@ -253,38 +273,133 @@
});
}
/* --- 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+$/, "");
/* --- 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
? "Attach every command you run to your next message"
: "This shell did not load LLeMbas's command markers, so there is no way " +
"to tell where one command's output ends.";
if (!usable && autoSend) setAuto(false);
}
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 : "";
}
function setAuto(on) {
autoSend = !!on;
var button = panel.querySelector("[data-terminal-auto]");
if (button) {
button.setAttribute("aria-pressed", autoSend ? "true" : "false");
button.classList.toggle("is-active", autoSend);
}
if (!text.trim()) {
say("Nothing to send: select some output first.");
return;
say(autoSend
? "Every command you run will be attached to your next message."
: "Commands are no longer attached automatically.");
}
/* 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;
var fence = "```\n" + text + "\n```\n";
input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + fence : fence;
input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + text : text;
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.");
/* 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 ------------------------------------------------------------- */
@@ -305,7 +420,15 @@
panel.addEventListener("click", function (event) {
if (event.target.closest("[data-terminal-send]")) {
event.preventDefault();
sendToChat();
return sendToChat();
}
if (event.target.closest("[data-terminal-copy]")) {
event.preventDefault();
return copyToClipboard();
}
if (event.target.closest("[data-terminal-auto]")) {
event.preventDefault();
return setAuto(!autoSend);
}
});
@@ -222,6 +222,22 @@
One per chat. Each holds an SSH connection open on the far machine.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="terminal_integration"
{{ 'checked' if values.terminal_integration }}>
<span>Mark where commands begin and end</span>
</label>
<p class="field__hint">
Gives bash and zsh the same invisible markers VS Code and WezTerm use,
so <strong>Copy</strong>, <strong>Send</strong> and the automatic
toggle know which output belongs to which command. Written by the shell
into a temporary file it deletes itself, and any other shell is started
exactly as it was before. Off means those buttons fall back to copying
the last of the screen as it appeared, wraps and all.
</p>
</div>
</section>
<section class="card">
+13 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="{{ theme }}">
<html lang="en" data-theme="{{ theme }}"{% if layout %} style="{{ layout }}"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -41,6 +41,18 @@
document.documentElement.dataset.theme = stored;
}
} catch (e) { /* private mode: the server-rendered theme stands */ }
/* Panel widths, for the same reason and in the same breath. app.js also
applies these, but it is deferred -- so without this a panel dragged
wider opens at its default and jumps once the script runs. */
try {
var widths = JSON.parse(localStorage.getItem("lembas-panel-widths") || "{}");
Object.keys(widths).forEach(function (name) {
if (name.indexOf("--") === 0) {
document.documentElement.style.setProperty(name, widths[name] + "px");
}
});
} catch (e) { /* the stylesheet's defaults stand */ }
})();
</script>
</head>
+33 -2
View File
@@ -14,18 +14,48 @@
data-terminal
data-url="/api/chats/{{ chat.id }}/terminal/ws"
data-label="{{ agent_profile.name if agent_profile else 'this connection' }}"
data-dir="{{ chat.project_dir }}">
data-dir="{{ chat.project_dir }}"
data-resize-target>
{#
The left edge, dragged. A separator rather than a decoration: it takes
focus and answers the arrow keys, or the panel is only resizable with a
mouse and the grip is a focus trap that does nothing.
#}
<div class="panel-resize" data-resize="--terminal-width" data-resize-min="384"
role="separator" aria-orientation="vertical" tabindex="0"
aria-label="Resize the terminal">
{{ icon("grip", "icon--sm") }}
</div>
<div class="panel-head">
<h2 class="panel-head__title">
{{ icon("terminal", "icon--sm") }}
<span>{{ agent_profile.name if agent_profile else "Terminal" }}</span>
<span class="terminal__where" data-terminal-where>{{ chat.project_dir }}</span>
</h2>
{#
Copy, Send and Auto. All three need to know where one command ends and
the next begins, which is what the shell integration provides; without it
the first two fall back to scraping the screen and say so, and Auto is
disabled rather than degraded. Forty arbitrary lines attached to every
message is worse than nothing attached at all.
#}
<button class="btn btn--icon btn--sm" type="button" data-terminal-copy
title="Copy the last command and its output"
aria-label="Copy the last command and its output">
{{ icon("copy", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-terminal-send
title="Put the selection, or the last of the output, into the message box"
title="Put the last command and its output into the message box"
aria-label="Send to chat">
{{ icon("arrow-up", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-terminal-auto
aria-pressed="false"
title="Attach every command you run to your next message"
aria-label="Send every command automatically">
{{ icon("sparkle", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
aria-label="Close terminal">
{{ icon("x", "icon--sm") }}
@@ -36,6 +66,7 @@
<div class="terminal__status">
<span class="terminal__message" data-terminal-message>Connecting…</span>
<span class="terminal__last" data-terminal-last></span>
<span>Ctrl+Shift+C / V</span>
</div>
</aside>
+29
View File
@@ -59,6 +59,34 @@ def resolve_theme(user: User | None) -> str:
return settings.default_theme
def resolve_layout(user: User | None) -> str:
"""Stored panel widths as a `style` value for <html>, or "".
Same shape as the theme and for the same reason: a first guess, corrected
from localStorage before first paint. This is what carries a dragged width
to a second browser, where localStorage has nothing to say.
Re-clamped on the way out rather than trusted from the column. The bounds
could have tightened since it was stored, and a width outside them is a
panel somebody cannot see well enough to drag back.
"""
if user is None:
return ""
from lembas.api.preferences import LAYOUT_BOUNDS
parts = []
for name, raw in ((user.settings_json or {}).get("layout") or {}).items():
bounds = LAYOUT_BOUNDS.get(str(name))
if bounds is None:
continue
try:
value = min(max(int(float(raw)), bounds[0]), bounds[1])
except (TypeError, ValueError):
continue
parts.append(f"{name}:{value}px")
return ";".join(parts)
def render(
request: Request,
template: str,
@@ -76,6 +104,7 @@ def render(
"request": request,
"user": user,
"theme": resolve_theme(user),
"layout": resolve_layout(user),
"version": __version__,
"allow_signup": settings.allow_signup,
}