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
+155 -4
View File
@@ -44,6 +44,7 @@ from collections import deque
from dataclasses import dataclass, field
from typing import Any
from lembas.services.agent import capture, shell_marks
from lembas.services.agent.base import ExecError
from lembas.services.agent.ssh import available, connect_kwargs
@@ -90,6 +91,20 @@ CLOSED_SHUTDOWN = "shutdown"
CLOSED_REVOKED = "revoked"
CLOSED_ERROR = "error"
# Whether this shell tells us where its commands begin and end.
# live -- it does
# loading -- the hooks went in; the first prompt has not arrived yet
# none -- it never will: an unknown shell, or a dotfile that replaced it
INTEGRATION_LIVE = "live"
INTEGRATION_LOADING = "loading"
INTEGRATION_NONE = "none"
# How long a shell may produce output without ever marking a prompt before we
# conclude it is not going to. This is what catches a `.bashrc` ending in `exec
# tmux`: the hooks were installed and then the shell replaced itself. Without
# it the buttons stay greyed out forever with no explanation.
INTEGRATION_GRACE = 10.0
@dataclass
class Viewer:
@@ -122,6 +137,7 @@ class Session:
idle_timeout: float = 1800.0,
cols: int = 80,
rows: int = 24,
integrate: bool = True,
) -> None:
self.chat_id = chat_id
self.owner_id = owner_id
@@ -134,6 +150,28 @@ class Session:
self._scrollback: deque[bytes] = deque()
self._scrollback_bytes = 0
# --- Command boundaries ---------------------------------------------
# Three states, and the middle one matters: INTEGRATION_LOADING means
# the hooks were installed and no marker has arrived yet, which is a
# different thing to tell somebody than "this shell will never mark".
self.integrate = integrate
self.integration = INTEGRATION_LOADING if integrate else INTEGRATION_NONE
self.shell = ""
self._marks = shell_marks.Marks(self._on_mark, self._on_text)
# At most two, ever. The one being written and the last finished one --
# a history would be a second scrollback with none of the bounding.
self.current: capture.Capture | None = None
self.last: capture.Capture | None = None
self._captures = 0
# Where the shell says it is, which the panel header shows live and a
# capture records. Seeded from the chat so it says something sensible
# before the first prompt.
self.cwd = project_dir
# Set by the socket layer, which knows how to shape a frame. Called
# when a command finishes so a panel can enable its buttons without
# polling for something that happens a few times a minute.
self.on_command: Any = None
self._conn: Any = None
self._process: Any = None
self._pump: asyncio.Task | None = None
@@ -213,11 +251,79 @@ class Session:
command nobody entered. It is single-quoted, and a failure is ignored:
a directory that has been deleted should leave somebody at a shell to
find out why, not with a connection that closes as it opens.
With integration on, the same string also writes the shell-integration
files and execs through them; see `shell_marks` for why it is done in
the command rather than over SFTP or through the environment.
"""
if not self.project_dir:
return None
quoted = "'" + self.project_dir.replace("'", "'\\''") + "'"
return f"cd {quoted} 2>/dev/null; exec ${{SHELL:-/bin/sh}} -l"
return shell_marks.command_for(self.project_dir, integrate=self.integrate)
# --- Where one command ends and the next begins --------------------------
def _on_mark(self, kind: str, value: str) -> None:
"""One marker, from the scanner in the pump.
Advisory, never trusted: a program can print these itself and move a
boundary. It is not a way in -- the text is sanitised and fenced either
way, and a program could already print anything on screen -- but that is
why nothing here validates them, and why none of it decides anything a
person could not already do at the keyboard.
"""
if kind == shell_marks.MARK_READY:
self.integration = INTEGRATION_LIVE
self.shell = value.split(";")[0][:32]
return
if self.integration != INTEGRATION_LIVE and kind in (
shell_marks.MARK_PROMPT,
shell_marks.MARK_OUTPUT,
):
self.integration = INTEGRATION_LIVE
if kind == shell_marks.MARK_CWD:
path = shell_marks.unescape(value.partition("=")[2])[:1000]
self.cwd = path
return
if kind == shell_marks.MARK_COMMAND:
self._captures += 1
self.current = capture.Capture(
seq=self._captures,
command=capture.trim_command(shell_marks.unescape(value)),
cwd=self.cwd,
)
return
if kind == shell_marks.MARK_DONE and self.current is not None:
try:
self.current.exit_status = int(value.strip() or 0)
except ValueError:
self.current.exit_status = 0
self.current.ended = time.monotonic()
self.last = self.current
self.current = None
if self.on_command is not None:
self.on_command(self.last)
def _on_text(self, data: bytes) -> None:
"""Everything that was not a marker, while a command is running.
Interleaved with `_on_mark` rather than applied to the whole chunk
afterwards: a shell often writes the command marker, the output and the
finished marker in one read, and absorbing after the scan would find
the capture already closed and keep nothing at all.
"""
if self.current is not None:
self.current.absorb(data)
def latest(self) -> capture.Capture | None:
"""The command to act on: the one still running, else the last one.
In-flight counts. "Copy the last command and its output" while `make` is
still going should give what has been printed so far, marked as still
running -- not "nothing yet".
"""
return self.current or self.last
# --- Following -----------------------------------------------------------
@@ -291,6 +397,9 @@ class Session:
if not data:
break
self._remember(data)
# Before the fan-out, so a "this command finished" frame can
# never reach a browser after the output it describes.
self._observe(data)
self._fan_out(data)
except asyncio.CancelledError:
raise
@@ -300,12 +409,52 @@ class Session:
return
await self._finish(CLOSED_EXITED)
def _observe(self, data: bytes) -> None:
"""Watch the stream for markers, and feed the command being captured.
Server-side rather than in the browser, for five reasons. The `behind`
path calls `term.reset()` and replays a *truncated* scrollback, so a
client parser routinely sees a "finished" with no matching "started".
Two tabs share one shell and two parsers can disagree about what "the
last command" is. The server sees the stream once however many are
watching. And what comes out of this ends up inside a prompt -- deriving
it here means there is nothing to disbelieve later.
The bytes are still fanned out unchanged, markers and all: xterm
consumes an OSC it has no handler for and never draws it, and rewriting
frames on the hot path would break the "nothing decodes, so nothing can
split" property the pump depends on.
"""
self._marks.feed(data)
if self.current is None and (
self.integration == INTEGRATION_LOADING
and time.monotonic() - self.started_at > INTEGRATION_GRACE
):
# Output arrived, the grace period passed, and no marker ever came.
# Output arrived, the grace period passed, and no marker ever came.
# Something replaced the shell -- a dotfile ending in `exec tmux` is
# the usual one. Say so rather than leaving the buttons greyed.
self.integration = INTEGRATION_NONE
def _remember(self, data: bytes) -> None:
self._scrollback.append(data)
self._scrollback_bytes += len(data)
while self._scrollback_bytes > SCROLLBACK_BYTES and len(self._scrollback) > 1:
self._scrollback_bytes -= len(self._scrollback.popleft())
def announce(self, text: str) -> None:
"""Put one text frame in front of every viewer.
Through the same queues as the output so ordering is preserved: a
"finished" that overtook the last of the output it describes would have
a panel offering a capture the screen has not caught up with. Dropped
rather than blocking on a full queue -- that viewer is already being
disconnected and will be told again on reattach.
"""
for viewer in list(self.viewers.values()):
with contextlib.suppress(asyncio.QueueFull):
viewer.queue.put_nowait(text)
def _fan_out(self, data: bytes) -> None:
for viewer in list(self.viewers.values()):
try:
@@ -414,6 +563,7 @@ async def open_session(
max_per_user: int = 3,
cols: int = 80,
rows: int = 24,
integrate: bool = True,
) -> Session:
"""The shell for this chat, opening one if it is not already there.
@@ -442,6 +592,7 @@ async def open_session(
owner_id=owner_id,
profile_id=profile_id,
label=label,
integrate=integrate,
project_dir=project_dir,
idle_timeout=idle_timeout,
cols=cols,