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
+94 -10
View File
@@ -107,7 +107,9 @@ src/lembas/
library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools
agent/ agent chats: the mode table, SSH, the four tools,
and terminal.py, the shells held open behind the panel
terminal.py (shells held open behind the panel),
shell_marks.py + capture.py (where one command ends),
index.py (what is in the project directory)
audio.py OpenAI-shaped /v1/audio/* client
fetch.py URL retrieval, HTML to text, the SSRF guard
sharing.py one visibility rule for every library store
@@ -133,6 +135,8 @@ src/lembas/
templating.py render() -- always use this, not TemplateResponse
templates/ Jinja
static/ css, js, vendor, img, sw.js
js/commands.js the / table, and the keyboard that does the same jobs
js/composer.js the menu that / and @ open, over the message box
assets/ SVG masters and PWA icons (generated)
deploy/ systemd unit, nginx vhost, install/update scripts
```
@@ -140,8 +144,9 @@ deploy/ systemd unit, nginx vhost, install/update scripts
## Things that will bite you
**`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects
`user`, `theme`, `version` and `allow_signup`. Templates assume they exist. If
you must call `templates.TemplateResponse` directly (the SSE path does, because
`user`, `theme`, `layout`, `version` and `allow_signup`. Templates assume they
exist. If you must call `templates.TemplateResponse` directly (the SSE path does,
because
there is no `Request`), pass `user` explicitly — `chat/_message.html` renders
both roles and the user branch dereferences it.
@@ -377,6 +382,41 @@ no longer in the transcript are still there. Nothing tries to undo them: the
project directory is somebody's real working tree, and deleting their work to
match would be far worse than the inconsistency.
**The project listing is read from a cache and never fetched.**
`harness.context_variables` runs synchronously on the request path, so
`agent/index.py:cached()` is all it may call — an SFTP round trip from there
would hold a request open while somebody's box thought about it. The walk
happens in `generation._warm_index`, which is async and already doing network
work, with a short wait. A chat whose first reply outruns its first walk simply
has no listing that turn, and the fragment's `requires` makes it vanish rather
than appear as an empty heading. Anything else wanting the listing gets the same
deal: the `@` picker offers no files until one exists, because a keystroke must
never wait on a machine.
**A listing is budgeted, not dumped.** A tree of a thousand files costs the
window on every request forever and buries the four names that mattered.
`index.render` collapses what will not fit to `src/vendor/ (412 files)` and says
so. Collapsing picks the **deepest and largest first**: by saving alone it would
take `src/` before `src/web/static/vendor/`, because it contains it, and lose
every name worth having. Watch the double-count — collapsing a parent subsumes a
child already collapsed, and adding both savings stops the loop early believing
it has made room it has not.
**A slash command must never swallow a message.** `static/js/commands.js`
intercepts only an exact match against its table; `//` escapes, and anything
unrecognised is sent as written. Eating somebody's message because it began with
a slash is a far worse failure than an unknown command, and it is the one the
implementation has to be arranged around rather than patched for afterwards.
**`@` inserts a reference *and* attaches the contents.** The token stays in the
sentence so "change the thing in @main.py" reads as one, and the file arrives as
an attachment chip — the same component every other attach path returns, so the
composer learns nothing new. `Attachment.source_path` and `source_label` carry
where it came from into `chat.document_context`'s tag, because a model handed
`main.py` cannot tell which of four it is looking at and cannot name it back when
asked to change something. Those two are attribute values in a tag we write, so
`_attr` strips quotes and angle brackets rather than escaping them.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -700,13 +740,57 @@ construction, while decoding each frame server-side would corrupt every
boundary. Only `resize`, `ready`, `closed` and `error` are text, and they are
JSON.
**The modes do not govern the keyboard.** `agent/policy.py` exists because a
model reads pages, files and command output it did not write and can be talked
into things. A person typing into the panel holds the credential already and
could open the same shell with an ssh client, so nothing they type is checked
against the mode or the two lists. There is a test named after this, because it
reads like a bug next to `policy.py` and "fixing" it would make the panel
useless in the mode people spend the most time in.
**The modes do not govern the keyboard, and now there are three exceptions, not
one.** `agent/policy.py` exists because a model reads pages, files and command
output it did not write and can be talked into things. A person typing into the
terminal panel holds the credential already and could open the same shell with
an ssh client, so nothing they type is checked against the mode or the two
lists. The directory browser (`GET /api/agents/{id}/browse`) and the project
listing (`agent/index.py`) are the same argument again: both are read-only, both
are LLeMbas acting on somebody's instruction rather than a model choosing to,
and both would be pointless if they asked. But it does mean **Manual** mode's
"everything is shown to you before it happens" is now true of the *model* and
not of the interface, and that is worth saying out loud rather than discovering.
There is a test named after the first one, because it reads like a bug next to
`policy.py` and "fixing" it would make the panel useless in the mode people
spend the most time in.
**A control wired to a method its route does not serve fails silently.** The
agent-mode select posted with `hx-post` against a route that only answers
`PATCH`, so every change returned 405 and the mode never moved — for the whole
life of the feature. htmx surfaces nothing on a failed request, so the select
stayed where it was put and the server ignored it, which looks exactly like
working. `tests/test_agent_mode.py` asserts the method is *refused* as well as
that the right one works, because only the second half would have passed
throughout. When adding a control that writes, check the verb against the route,
and assert on the row rather than on the response.
**Shell integration is best-effort, and the fallback is the point.**
`agent/shell_marks.py` gives bash and zsh hooks that emit OSC 133 around the
prompt, the command and its result, so the panel can say what "the last command
and its output" means. Three things about it:
- **It 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`. Environment
variables do 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, and lands in shell history.
- **Nothing needs hiding.** 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. That is why this mechanism was chosen over the one that looks obvious.
- **The exit status is captured in the `DEBUG` trap, not in `PROMPT_COMMAND`.**
DEBUG fires before every simple command *including each one inside
`PROMPT_COMMAND`*, so `$?` read from there is whatever ran a moment ago. This
was wrong in the first version and every command reported success. zsh has the
mirror-image trap: `$ZDOTDIR` is already ours by the time `.zshenv` runs, so
the user's own must be passed on the exec line or the shims source themselves
and none of somebody's configuration loads.
Any shell that is not bash or zsh gets exactly the command that ran before, and
therefore no markers — at which point Copy and Send fall back to scraping the
screen and say so, and the automatic toggle is **disabled rather than degraded**.
Forty arbitrary lines attached to every message is worse than nothing attached.
**The nginx vhost must pass upgrades through.** `deploy/nginx-vhost.conf` used
to set `Connection ""`, which is right for SSE and fails every WebSocket