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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user