"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>
93 lines
4.1 KiB
HTML
93 lines
4.1 KiB
HTML
<!doctype html>
|
|
<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">
|
|
<title>{% block title %}LLeMbas{% endblock %}</title>
|
|
<meta name="description" content="LLeMbas - a web UI for your language models.">
|
|
<meta name="color-scheme" content="dark light">
|
|
|
|
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
|
|
|
|
{#
|
|
Installing as an app. The manifest is a route, not a file, because it carries
|
|
the instance name; the icons are PNG because a launcher will not take an SVG.
|
|
theme-color is rewritten by app.js when the theme changes -- the value here is
|
|
only what the browser paints with before the stylesheet has resolved.
|
|
#}
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<meta name="theme-color" content="#101317">
|
|
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon-180.png') }}">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-title" content="LLeMbas">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
|
|
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
|
{% block head %}{% endblock %}
|
|
|
|
{#
|
|
Applied before first paint so a reload never flashes the wrong theme. The
|
|
server already rendered data-theme from the signed-in user's preference; this
|
|
only corrects it for a visitor whose local choice differs (or who is signed
|
|
out, where the server has nothing to go on).
|
|
#}
|
|
<script>
|
|
(function () {
|
|
try {
|
|
var stored = localStorage.getItem("lembas-theme");
|
|
if (stored && stored !== document.documentElement.dataset.theme) {
|
|
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>
|
|
<body{% block body_attrs %}{% endblock %}>
|
|
{% include "partials/icons.html" %}
|
|
|
|
{% block body %}{% endblock %}
|
|
|
|
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
|
|
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
|
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
|
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
|
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
|
|
{# commands.js before composer.js: the second reads the first's table to draw
|
|
the `/` menu, and both are deferred so the order here is the run order. #}
|
|
<script src="{{ url_for('static', path='js/commands.js') }}" defer></script>
|
|
<script src="{{ url_for('static', path='js/composer.js') }}" defer></script>
|
|
<script src="{{ url_for('static', path='js/audio.js') }}" defer></script>
|
|
|
|
{#
|
|
The version in the query string is what versions the worker's cache, so a
|
|
release invalidates it without anyone remembering to bump a constant.
|
|
serviceWorker is absent over plain http, which is why a LAN install without
|
|
TLS silently offers no install prompt -- that is the browser's rule, not ours.
|
|
#}
|
|
<script>
|
|
if ("serviceWorker" in navigator) {
|
|
window.addEventListener("load", function () {
|
|
navigator.serviceWorker.register("/sw.js?v={{ version }}").catch(function () {
|
|
/* An install failure must never break the page it was loaded from. */
|
|
});
|
|
});
|
|
}
|
|
</script>
|
|
{% block scripts %}{% endblock %}
|
|
</body>
|
|
</html>
|