diff --git a/README.md b/README.md index 7e71464..917bdec 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,13 @@ runtime. Clone it, `pip install -e .`, run it. - **Attachments** — drag, paste or pick images, PDFs and text files. Images are downscaled and sent to vision models; PDF and text content is extracted and put in the prompt +- **`@` to name something** — a document from your library, or in an agent chat + a file in the project directory. The reference stays in the sentence you are + writing and the contents come with it +- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/model`, + `/title`, `/terminal`, `/theme`. `/help` lists them and the keyboard + shortcuts beside them. A message that merely starts with a slash is still + sent as written - **Folders** — arbitrarily nested, delete a folder without losing the chats inside it - **Web search** — offered to the model as a tool it calls when a question needs @@ -178,9 +185,10 @@ fingerprint with nothing sent — not your username, not your key — and only accepting pins it. If that host later answers with a different key, it is refused rather than quietly trusted. -Then start a chat with the **Agent** toggle, pick the connection and a -directory, and choose a mode. The mode is in the chat header and changes at any -time: +Then start a chat with the **Agent** toggle, pick the connection, browse to a +directory, and choose a mode — all of it under the message box, before you send +anything. The connection and the directory are fixed once the chat exists; the +mode changes at any time and stays where you chose it: | | Reads | Writes files | Runs commands | |---|---|---|---| @@ -198,6 +206,23 @@ with. In **Auto**, nothing stands between that and a command running. switches to *Edit*, never *Auto*, because the plan was written under a mode where every command still asked. +#### What the model knows about the directory + +An agent chat starts by listing the project directory, so a reply does not spend +its first rounds finding out what is there. It is one read-only command — +`git ls-files` in a repository, so `.gitignore` is honoured for free, otherwise +`find` with the usual noise pruned — and it is cached and shared by every chat +pointed at the same place. + +What reaches the model is budgeted rather than dumped: a directory that will not +fit is shown as `node_modules/ (4,102 files)` and the model is told to open it +itself if it needs to. **Admin → Agents** sets the budget, and `0` keeps the +listing for the `@` picker while putting none of it in the prompt. + +Listing a directory and browsing one are things *you* asked for, not things a +model chose, so neither goes through the modes above. Worth knowing if you read +**Manual** as "nothing happens without me": it means nothing the *model* does. + #### The terminal An agent chat has a **Terminal** button in its header, which opens a real shell @@ -207,9 +232,23 @@ the *Open a terminal* permission, which is off by default. The modes above do not apply to it. They exist because a model reads pages, files and command output it did not write; you hold the credential and could open the same shell with an ssh client, so nothing you type is queued for your -own approval. The model cannot see the panel either — the button in its header -puts the selection, or the last of the output, into the message box, where you -read it before it goes anywhere. +own approval. The model cannot see the panel either — three buttons in its +header decide what it sees: **Copy** takes the last command and its output to +the clipboard, **Send** puts the same into the message box, and **Auto** +collects every command you run into your next message. Nothing is ever sent on +its own; the box is where you read it first. + +Knowing what "the last command" means takes a little help from the shell. +LLeMbas gives bash and zsh the same invisible markers VS Code and WezTerm use, +written into a temporary file the shell deletes itself, so it can tell one +command's output from the next and record the exit status and the directory. +Your own dotfiles are loaded first and nothing of yours is skipped. Any other +shell starts exactly as it would have; the two buttons then copy the last of the +screen as it appeared, say so, and Auto is switched off rather than guessing. + +Drag the panel's left edge to make it wider — a terminal narrower than eighty +columns re-wraps everything a program prints — and the width follows you to +another browser. The shell is not tied to the panel. Close it and a build carries on; come back, or reload, and you reattach with the scrollback. Two tabs share one shell, and diff --git a/src/lembas/api/admin_agents.py b/src/lembas/api/admin_agents.py index 108e26b..3bda565 100644 --- a/src/lembas/api/admin_agents.py +++ b/src/lembas/api/admin_agents.py @@ -72,6 +72,7 @@ async def save_agents( terminal_idle_timeout: int = Form(1800), terminal_max_sessions: int = Form(20), terminal_max_per_user: int = Form(3), + terminal_integration: bool = Form(False), index_enabled: bool = Form(False), index_chars: int = Form(2000), ) -> Response: @@ -96,6 +97,7 @@ async def save_agents( "terminal_idle_timeout": min(max(terminal_idle_timeout, 60), 86400), "terminal_max_sessions": min(max(terminal_max_sessions, 1), 500), "terminal_max_per_user": min(max(terminal_max_per_user, 1), 50), + "terminal_integration": terminal_integration, "index_enabled": index_enabled, # Zero is kept rather than clamped up: it means "list the # directory for the file picker but put none of it in the diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py index 4e68a39..c268421 100644 --- a/src/lembas/api/preferences.py +++ b/src/lembas/api/preferences.py @@ -38,6 +38,44 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru return {"ok": True, "theme": theme} +# Which CSS variables a browser is allowed to set from here, and how far. An +# open dict would let a page store anything under somebody's account and have +# it read back on every load; a width outside these bounds would hand them a +# panel they cannot see to drag back. +LAYOUT_BOUNDS = { + "--terminal-width": (384, 2400), + "--inspector-width": (280, 2400), + "--sidebar-width": (200, 800), +} + + +@router.post("/layout") +async def set_layout(db: Db, user: RequiredUser, widths: dict = Body(...)) -> dict: + """Remember how wide somebody dragged the panels. + + Same two tiers as the theme: `localStorage` is the truth for the tab that + did the dragging, and this is what carries it to another browser. Unknown + names are dropped rather than refused -- an older browser sending a key a + newer release removed should not fail the request. + """ + kept: dict[str, int] = {} + for name, raw in (widths or {}).items(): + bounds = LAYOUT_BOUNDS.get(str(name)) + if bounds is None: + continue + try: + value = int(float(raw)) + except (TypeError, ValueError): + continue + kept[str(name)] = min(max(value, bounds[0]), bounds[1]) + + settings = {**(user.settings_json or {})} + settings["layout"] = {**(settings.get("layout") or {}), **kept} + user.settings_json = settings + db.commit() + return {"ok": True, "layout": kept} + + @router.post("/default-model") async def set_default_model( db: Db, user: RequiredUser, model_id: str = Form("") diff --git a/src/lembas/api/terminal.py b/src/lembas/api/terminal.py index 51848b2..8ff15e8 100644 --- a/src/lembas/api/terminal.py +++ b/src/lembas/api/terminal.py @@ -27,8 +27,9 @@ import json import logging from urllib.parse import urlsplit -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status +from lembas.api.deps import Db, RequiredUser from lembas.db.models import KIND_AGENT, Chat from lembas.db.session import session_scope from lembas.security import permissions @@ -111,6 +112,7 @@ def _prepare(db, user, chat_id: str) -> tuple[str, dict]: "idle_timeout": float(values["terminal_idle_timeout"]), "max_sessions": int(values["terminal_max_sessions"]), "max_per_user": int(values["terminal_max_per_user"]), + "integrate": bool(values.get("terminal_integration", True)), } @@ -148,6 +150,13 @@ async def terminal_socket( await _refuse(websocket, "The shell could not be started.") return + # Shaping a frame is this layer's job, not the session's; the session only + # knows it finished something. Reassigned per socket and harmless: every + # socket on this session would build the identical frame. + session.on_command = lambda found: session.announce( + json.dumps({"t": "command", "command": _command_frame(found)}) + ) + viewer = session.attach(cols, rows) await websocket.send_text( json.dumps( @@ -160,6 +169,10 @@ async def terminal_socket( # Two tabs share one shell, and a size neither of them chose is # otherwise a mystery. "shared": len(session.viewers) > 1, + # Whether this shell will tell us where commands begin and end, + # which is what the Copy and Send buttons are made of. + "integration": session.integration, + "last": _command_frame(session.latest()), } ) ) @@ -182,6 +195,63 @@ async def terminal_socket( session.detach(viewer) +@router.get("/{chat_id}/terminal/last") +async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict: + """The last command and its output, rendered ready to paste. + + The *server* renders the text, so Copy and Send are a fetch and a + clipboard write with no formatting logic in the browser -- and the block a + model eventually reads exists in exactly one place. The panel's own screen + buffer could not produce it anyway: it holds what is on screen, hard-wrapped + at the terminal's width, with no way to tell a wrap from a newline. + """ + chat = db.get(Chat, chat_id) + if chat is None or chat.user_id != user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.") + if not permissions.has(db, user, "agent.terminal"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.") + + session = terminal_service.get(chat_id) + found = session.latest() if session is not None else None + if session is None or found is None: + return { + "ok": False, + "message": "Nothing has been run in this shell yet." + if session is not None + else "This terminal is not open.", + } + + return { + "ok": True, + "command": found.command, + "cwd": found.cwd, + "exit": found.exit_status, + "running": found.running, + "summary": found.summary(), + "text": found.as_text(label=session.label), + } + + +def _command_frame(found) -> dict | None: + """A finished command, small enough to push at every viewer. + + Tens of bytes, and deliberately *not* the output: a 64KB text frame would + compete with PTY bytes on the one path that has to stay responsive, and the + two buttons are pressed by a person, where a request is the natural shape. + """ + if found is None: + return None + return { + "seq": found.seq, + "command": found.command, + "cwd": found.cwd, + "exit": found.exit_status, + "running": found.running, + "ms": found.duration_ms, + "summary": found.summary(), + } + + async def _to_browser(websocket: WebSocket, session, viewer) -> None: """Everything the shell says, plus the one frame that says it stopped.""" while True: @@ -198,6 +268,11 @@ async def _to_browser(websocket: WebSocket, session, viewer) -> None: with contextlib.suppress(Exception): await websocket.send_text(json.dumps(payload)) return + # A string in the queue is a control frame that had to keep its place + # in the stream -- see `Session.announce`. + if isinstance(chunk, str): + await websocket.send_text(chunk) + continue await websocket.send_bytes(chunk) diff --git a/src/lembas/services/agent/capture.py b/src/lembas/services/agent/capture.py new file mode 100644 index 0000000..f1ea70f --- /dev/null +++ b/src/lembas/services/agent/capture.py @@ -0,0 +1,164 @@ +"""One command and its output, kept so it can be handed to a model. + +Bounded at both ends rather than only the front. A build that fails ten +megabytes in has the invocation and the configuration at the top and the error +at the bottom, and either half alone is the wrong half. + +Raw bytes are kept and decoded only when somebody asks. Head/tail slicing +splits UTF-8 characters at will, and `base.clean_output` decodes with +`errors="replace"`, which is exactly the right handling -- decoding eagerly per +chunk would be the same mistake the terminal pump already avoids. +""" + +from __future__ import annotations + +import re +import time +from collections import deque +from dataclasses import dataclass, field + +from lembas.services.agent.base import clean_output + +# What one command's output may keep, at each end. +CAPTURE_HEAD_BYTES = 48 * 1024 +CAPTURE_TAIL_BYTES = 16 * 1024 +# The command line itself. Longer than any command and shorter than a paste. +CAPTURE_COMMAND_BYTES = 4 * 1024 +# One line of output. A minified bundle on one line is not worth keeping whole. +MAX_LINE_CHARS = 2000 + +# C0 except tab and newline, and the C1 block. Not in `clean_output`, which +# `shell_run` shares: there a control character inside a file's contents is +# data. Here it is a terminal being driven. +_CONTROLS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") + + +def flatten(text: str) -> str: + """What the screen would have shown, from what the wire carried. + + The highest-value transform here by a distance. A progress bar redraws + itself by returning to the start of the line and writing again; keeping + every state turns two megabytes of `pip install` into two megabytes of + spinner in somebody's prompt. Only the last state of a line was ever + visible, so only the last state is kept. + """ + lines = [] + for line in text.replace("\r\n", "\n").split("\n"): + if "\r" in line: + line = line.rsplit("\r", 1)[-1] + lines.append(_CONTROLS.sub("", line)[:MAX_LINE_CHARS]) + return "\n".join(lines).strip("\n") + + +def fenced(text: str) -> str: + """A fence long enough that the content cannot end it early. + + Output containing three backticks would otherwise break out, and everything + after it would read to the model as prose rather than as what a machine + printed. That is a real injection route and it costs one line to close. + """ + longest = max((len(run) for run in re.findall(r"`+", text)), default=0) + ticks = "`" * max(3, longest + 1) + return f"{ticks}console\n{text}\n{ticks}" + + +@dataclass +class Capture: + """A command, and as much of its output as is worth keeping.""" + + seq: int = 0 + command: str = "" + cwd: str = "" + started: float = field(default_factory=time.monotonic) + ended: float = 0.0 + exit_status: int | None = None # None while it is still running + + head: bytearray = field(default_factory=bytearray) + tail: deque[bytes] = field(default_factory=deque) + tail_bytes: int = 0 + dropped: int = 0 + total: int = 0 + + @property + def running(self) -> bool: + return self.exit_status is None + + @property + def duration_ms(self) -> int: + end = self.ended or time.monotonic() + return int((end - self.started) * 1000) + + def absorb(self, chunk: bytes) -> None: + """Keep the front, keep the back, count what fell out of the middle.""" + self.total += len(chunk) + if len(self.head) < CAPTURE_HEAD_BYTES: + take = CAPTURE_HEAD_BYTES - len(self.head) + self.head += chunk[:take] + chunk = chunk[take:] + if not chunk: + return + self.tail.append(chunk) + self.tail_bytes += len(chunk) + while self.tail_bytes > CAPTURE_TAIL_BYTES and len(self.tail) > 1: + gone = self.tail.popleft() + self.tail_bytes -= len(gone) + self.dropped += len(gone) + + def output(self) -> str: + """The kept output as text, with the gap marked if there is one.""" + head = flatten(clean_output(bytes(self.head), limit=CAPTURE_HEAD_BYTES * 2)[0]) + if not self.dropped and not self.tail: + return head + tail = flatten(clean_output(b"".join(self.tail), limit=CAPTURE_TAIL_BYTES * 2)[0]) + if not self.dropped: + return f"{head}\n{tail}" if tail else head + gap = f"\n\n… {self.dropped / 1024:,.0f} KB dropped …\n\n" + return f"{head}{gap}{tail}" + + def as_text(self, *, label: str) -> str: + """The block that goes into a message, attribution and all. + + The sentence sits **outside** the fence and is written here, so nothing + the far side printed can forge it, and the `$ ` line is synthesised + rather than lifted from the shell -- what the shell echoed carries + readline's editing escapes and is not the command. + """ + where = f", in {self.cwd}" if self.cwd else "" + if self.running: + how = "still running" + elif self.exit_status: + how = f"exit {self.exit_status}" + else: + how = "succeeded" + + seconds = self.duration_ms / 1000 + took = f" after {seconds:.0f}s" if seconds >= 1 else "" + body = f"$ {self.command}\n{self.output()}".rstrip() + return ( + f"Ran in the terminal on {label}{where} — {how}{took}:\n\n{fenced(body)}" + ) + + def summary(self) -> str: + """A short label for a chip, never rendered as markup.""" + command = self.command or "(no command)" + if len(command) > 60: + command = command[:57] + "…" + if self.running: + return f"{command} · running" + return f"{command} · exit {self.exit_status}" + + +def trim_command(raw: str) -> str: + text, _ = clean_output(raw, limit=CAPTURE_COMMAND_BYTES) + return _CONTROLS.sub("", text).strip() + + +__all__ = [ + "CAPTURE_COMMAND_BYTES", + "CAPTURE_HEAD_BYTES", + "CAPTURE_TAIL_BYTES", + "Capture", + "fenced", + "flatten", + "trim_command", +] diff --git a/src/lembas/services/agent/shell_marks.py b/src/lembas/services/agent/shell_marks.py new file mode 100644 index 0000000..ef1d681 --- /dev/null +++ b/src/lembas/services/agent/shell_marks.py @@ -0,0 +1,401 @@ +"""Knowing where one command ends and the next begins, in the terminal panel. + +Without this the panel can offer "the last forty rows of the screen", which is +hard-wrapped at the terminal's width with no way to tell a wrap from a newline. +That is not something to hand a model and call it the output of a command. + +So the shell is given hooks that emit invisible markers around the prompt, the +command and its result -- OSC 133, which is what VS Code, WezTerm and Ghostty +all use, plus two of VS Code's private codes for the things 133 has no room +for. A real terminal that understands 133 is not confused by ours, and one that +does not consumes and discards them, which is why the bytes are fanned out to +the browser unchanged. + +**Three things about the mechanism.** + +The integration is written by the PTY command string itself, with `printf`. +sshd runs that string through `$SHELL -c`, so it can branch 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: sshd's `AcceptEnv` is `LANG LC_*` on +every distribution anybody runs, so the variable is dropped silently -- and +bash reads `$BASH_ENV` only when non-interactive anyway. Feeding `source …` in +as keystrokes does work, and races a slow `.zshrc`, echoes into the scrollback, +and lands in shell history with no portable way to remove it. + +Nothing needs hiding, and that is the point of choosing this mechanism. The +setup runs before the shell exists and never writes to the PTY's *input* side, +so there is nothing for the tty to echo. The first byte a viewer sees is the +first byte of their own prompt. + +There is deliberately no `133;B`. It has to live at the end of `PS1`, and any +theme that rebuilds the prompt in a hook drops it silently every time. Every +marker here comes from a shell hook instead, so none of them depends on a +prompt string surviving somebody's dotfiles. + +**Markers are advisory.** A program can print `\\e]133;D;0\\a` and move a +boundary. That is not a security problem -- the captured text is sanitised and +fenced either way, and a program could already print anything on screen -- but +nobody should later try to "validate" them. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable + +# Everything ours is under these two. 133 is FinalTerm's de-facto convention; +# 633 is VS Code's private space, borrowed because the raw stream carries the +# *echoed* command with readline's editing escapes in it and is not +# recoverable, and there is no standard code for "here is the command line". +MARK_PROMPT = "A" # 133;A -- a prompt is about to be drawn +MARK_OUTPUT = "C" # 133;C -- output starts here +MARK_DONE = "D" # 133;D; +MARK_COMMAND = "E" # 633;E; +MARK_CWD = "P" # 633;P;Cwd= +MARK_READY = "LEMBAS" # 633;LEMBAS;;1 + +# A marker longer than this is not one of ours. `cat` of a binary file produces +# stray ESC ] regularly, and without a bound one of them would swallow the rest +# of the session into a buffer that never emptied. +MAX_MARKER_BYTES = 8 * 1024 + +_ESC = 0x1B +_BEL = 0x07 + + +# --- The snippets ------------------------------------------------------------ +# `__lembas_esc` exists because an OSC payload may contain no BEL and no ESC +# (either would end it ambiguously) and no ';' (which would split the fields). +# Everything else rides through. It is also what lets `base.clean_output`'s +# existing OSC pattern swallow a whole marker in one bite. + +BASH_RC = r""" +# LLeMbas shell integration. +# +# --rcfile replaces bash's normal startup files rather than adding to them, so +# bash's login sequence is reproduced here, in bash's own order, and nothing +# anybody has in a dotfile is skipped. +if [ -r /etc/profile ]; then . /etc/profile; fi +for __lembas_rc in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + if [ -r "$__lembas_rc" ]; then . "$__lembas_rc"; break; fi +done +unset __lembas_rc +if [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi + +__lembas_esc() { + local s=${1//\\/\\\\} + s=${s//;/\\x3b}; s=${s//$'\n'/\\x0a}; s=${s//$'\r'/\\x0d} + s=${s//$'\e'/\\x1b}; s=${s//$'\a'/\\x07} + builtin printf '%s' "$s" +} + +__lembas_emit() { + if [ -n "$__lembas_running" ]; then + builtin printf '\e]133;D;%s\a' "${__lembas_last:-0}" + __lembas_running= + __lembas_have= + fi + builtin printf '\e]633;P;Cwd=%s\a' "$(__lembas_esc "$PWD")" + builtin printf '\e]133;A\a' + __lembas_armed=1 +} + +# $BASH_COMMAND is the current *simple* command, so `a | b` would give "a". +# `history 1` is the whole line as typed, which is what somebody would +# recognise; it falls back when history is off. +__lembas_line() { + local h + h=$(HISTTIMEFORMAT='' builtin history 1 2>/dev/null) || { + builtin printf '%s' "$BASH_COMMAND"; return; } + if [[ $h =~ ^[[:space:]]*[0-9]+[[:space:]]+(.*)$ ]]; then + builtin printf '%s' "${BASH_REMATCH[1]}" + else + builtin printf '%s' "$BASH_COMMAND" + fi +} + +# The exit status is captured *in the DEBUG trap*, not in PROMPT_COMMAND. +# +# This is the one genuinely subtle thing in the file. DEBUG fires before every +# simple command -- including each command inside PROMPT_COMMAND -- so anything +# reading $? from there has already had it overwritten by whatever ran a moment +# earlier, and by this trap's own command substitution. The first DEBUG firing +# after the user's command is the last place the real status exists, so it is +# taken there and held until the prompt emits it. +# +# `__lembas_have` is what stops the later firings (the ones inside +# PROMPT_COMMAND) overwriting it, and `return $__s` puts $? back so nothing +# downstream sees a status this trap invented. +__lembas_debug() { + local __s=$? + if [ -n "$__lembas_running" ] && [ -z "$__lembas_have" ]; then + __lembas_last=$__s + __lembas_have=1 + fi + if [ -n "$__lembas_armed" ]; then + __lembas_armed= + builtin printf '\e]633;E;%s\a' "$(__lembas_esc "$(__lembas_line)")" + builtin printf '\e]133;C\a' + __lembas_running=1 + fi + return $__s +} + +trap '__lembas_debug' DEBUG + +# Appended, so anything already there still runs and runs first. +if [ -n "${BASH_VERSINFO[0]}" ] && [ "${BASH_VERSINFO[0]}" -ge 5 ] \ + && [ "${PROMPT_COMMAND@a}" = "a" ]; then + PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" __lembas_emit) +else + PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }__lembas_emit" +fi + +builtin printf '\e]633;LEMBAS;bash;1\a' + +# Self-deleting: bash has read the whole file by the time this runs, and one +# left in /tmp that a later shell might source is worse than any benefit. +rm -rf -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null +""" + +# zsh re-reads $ZDOTDIR before *each* startup file, so every shim points it back +# at the user's directory, sources their file, and takes it again -- otherwise +# zsh finds the user's .zshrc and ours never loads. +# +# `LEMBAS_USER_ZDOTDIR` is passed in on the exec line and must not be guessed +# here. By the time .zshenv runs, `$ZDOTDIR` is already *our* directory, so a +# `${ZDOTDIR:-$HOME}` fallback in this file captures the wrong path and the +# shim ends up sourcing itself -- which looks like it works, right up to the +# point somebody notices none of their own configuration is loaded. +_ZSH_SHIM = r""" +: ${LEMBAS_USER_ZDOTDIR:=$HOME} +ZDOTDIR=$LEMBAS_USER_ZDOTDIR +[[ -r $ZDOTDIR/%(file)s ]] && source $ZDOTDIR/%(file)s +LEMBAS_USER_ZDOTDIR=$ZDOTDIR +ZDOTDIR=$LEMBAS_DIR +""" + +ZSH_ENV = _ZSH_SHIM % {"file": ".zshenv"} +ZSH_PROFILE = _ZSH_SHIM % {"file": ".zprofile"} + +ZSH_RC = ( + _ZSH_SHIM % {"file": ".zshrc"} + + r""" +__lembas_esc() { + local s=${1//\\/\\\\} + s=${s//;/\\x3b}; s=${s//$'\n'/\\x0a}; s=${s//$'\r'/\\x0d} + s=${s//$'\e'/\\x1b}; s=${s//$'\a'/\\x07} + builtin print -rn -- $s +} + +__lembas_precmd() { + local __s=$? + if [[ -n $__lembas_running ]]; then + builtin printf '\e]133;D;%s\a' $__s + __lembas_running= + fi + builtin printf '\e]633;P;Cwd=%s\a' "$(__lembas_esc $PWD)" + builtin printf '\e]133;A\a' +} + +# $1 is the line as typed, before alias and glob expansion -- what somebody +# would recognise. $2 and $3 are progressively more expanded and less useful. +__lembas_preexec() { + builtin printf '\e]633;E;%s\a' "$(__lembas_esc $1)" + builtin printf '\e]133;C\a' + __lembas_running=1 +} + +# Prepended, not appended: whichever precmd runs first is the only one that +# sees the real $?, and a theme's hook will have clobbered it by the time a +# later one runs. +precmd_functions=(__lembas_precmd $precmd_functions) +preexec_functions=(__lembas_preexec $preexec_functions) + +builtin printf '\e]633;LEMBAS;zsh;1\a' +""" +) + +ZSH_LOGIN = ( + _ZSH_SHIM % {"file": ".zlogin"} + + r""" +# The last file zsh reads, so this is where ZDOTDIR goes back for good. +ZDOTDIR=$LEMBAS_USER_ZDOTDIR +[[ -n $LEMBAS_DIR ]] && rm -rf -- $LEMBAS_DIR +unset LEMBAS_DIR LEMBAS_USER_ZDOTDIR +""" +) + + +def quote(value: str) -> str: + """A single-quoted shell word, with embedded quotes escaped.""" + return "'" + value.replace("'", "'\\''") + "'" + + +def command_for(project_dir: str, *, integrate: bool = True) -> str | None: + """What the PTY runs. + + Returns None for the account's plain login shell in the one case that has + always returned it -- no project directory and no integration -- so the + existing behaviour is byte for byte what it was. + + `$SHELL -c` is what sshd puts this through, so it is POSIX sh and branches + on the shell's own name. Anything that is not bash or zsh falls out of the + `case` into exactly the line that was here before, because a terminal that + works without markers is worth more than markers that break a terminal. + Every step is `2>/dev/null`, so a full `/tmp` or a read-only home costs the + markers and nothing else. + """ + cd = f"cd {quote(project_dir)} 2>/dev/null; " if project_dir else "" + if not integrate: + if not project_dir: + return None + return f"{cd}exec ${{SHELL:-/bin/sh}} -l" + + return ( + f"{cd}umask 077; " + # mkdir -m 700 fails on a path that already exists, which is what makes + # the fallback safe when mktemp is missing. + '__L=$(mktemp -d 2>/dev/null) || { ' + '__L=${TMPDIR:-/tmp}/.lembas-$$-$RANDOM; mkdir -m 700 "$__L"; }; ' + "case ${SHELL##*/} in " + f' bash) printf %s {quote(BASH_RC)} > "$__L/rc" 2>/dev/null && ' + ' exec bash --rcfile "$__L/rc" -i ;; ' + f' zsh) printf %s {quote(ZSH_ENV)} > "$__L/.zshenv" 2>/dev/null && ' + f' printf %s {quote(ZSH_PROFILE)} > "$__L/.zprofile" 2>/dev/null && ' + f' printf %s {quote(ZSH_RC)} > "$__L/.zshrc" 2>/dev/null && ' + f' printf %s {quote(ZSH_LOGIN)} > "$__L/.zlogin" 2>/dev/null && ' + # The user's own ZDOTDIR is captured *here*, before it is replaced. + ' LEMBAS_DIR="$__L" LEMBAS_USER_ZDOTDIR="${ZDOTDIR:-$HOME}" ' + ' ZDOTDIR="$__L" exec zsh -l ;; ' + "esac; " + # Everything that did not exec lands here: an unknown shell, a failed + # mkdir, a full /tmp. You still get a shell. + 'rm -rf -- "$__L" 2>/dev/null; exec ${SHELL:-/bin/sh} -l' + ) + + +# --- Reading them back ------------------------------------------------------- +_UNESCAPE = re.compile(r"\\x([0-9a-fA-F]{2})") + + +def unescape(value: str) -> str: + """Undo `__lembas_esc`.""" + return _UNESCAPE.sub(lambda m: chr(int(m.group(1), 16)), value).replace("\\\\", "\\") + + +class Marks: + """Pulls the markers out of a stream that arrives in any pieces. + + Deliberately not a regex over the scrollback. The pump is handed 64 KB at a + time on no particular boundary, so the ESC and the `]` land in different + frames often enough to matter -- which is the same reason nothing here + decodes the bytes. This holds at most one partial marker and nothing else. + """ + + def __init__( + self, + on_mark: Callable[[str, str], None], + on_text: Callable[[bytes], None] | None = None, + ) -> None: + self._on_mark = on_mark + self._on_text = on_text + self._buffer = bytearray() + self._in_marker = False + self._plain = bytearray() + + def feed(self, data: bytes) -> None: + """Split the stream into markers and everything else. + + Both come back *in order* and as they are found, not after the whole + chunk has been scanned. That matters: a shell frequently writes the + command marker, the output and the finished marker in one 64KB read, so + anything that scanned first and absorbed afterwards would find the + capture already closed and keep nothing. + """ + for byte in data: + if not self._in_marker: + # A lone ESC is held: the ']' may be in the next frame. + if byte == _ESC: + self._flush() + self._buffer = bytearray([byte]) + self._in_marker = True + else: + self._plain.append(byte) + continue + + if len(self._buffer) == 1: + if byte != 0x5D: # ']' -- some other escape sequence + # Not ours, so it is output like anything else. Emitted + # rather than dropped: `clean_output` strips it later, and + # swallowing it here would silently eat a colour change. + self._in_marker = False + self._plain += self._buffer + self._plain.append(byte) + self._buffer.clear() + continue + self._buffer.append(byte) + continue + + # Two legal terminators, and shells in the wild use both: BEL, and + # ESC \ (ST). The ESC has to be *appended* rather than treated as + # the start of something new, or the ST branch below can never fire. + if byte == _BEL: + self._finish() + continue + + self._buffer.append(byte) + if self._buffer.endswith(b"\x1b\\"): + del self._buffer[-2:] + self._finish() + continue + # An `ESC ]` inside a marker that was never terminated starts a new + # one. Without this a truncated marker would eat the next. + if self._buffer.endswith(b"\x1b]"): + self._buffer = bytearray(b"\x1b]") + continue + if len(self._buffer) > MAX_MARKER_BYTES: + # Not one of ours. Output is worth more than a marker. + self._in_marker = False + self._plain += self._buffer + self._buffer.clear() + + self._flush() + + def _flush(self) -> None: + if not self._plain: + return + if self._on_text is not None: + self._on_text(bytes(self._plain)) + self._plain.clear() + + def _finish(self) -> None: + payload = bytes(self._buffer[2:]).decode("utf-8", "replace") + self._in_marker = False + self._buffer.clear() + + code, _, rest = payload.partition(";") + if code not in ("133", "633") or not rest: + return + kind, _, value = rest.partition(";") + self._on_mark(kind, value) + + +__all__ = [ + "BASH_RC", + "MARK_COMMAND", + "MARK_CWD", + "MARK_DONE", + "MARK_OUTPUT", + "MARK_PROMPT", + "MARK_READY", + "MAX_MARKER_BYTES", + "Marks", + "ZSH_ENV", + "ZSH_LOGIN", + "ZSH_PROFILE", + "ZSH_RC", + "command_for", + "quote", + "unescape", +] diff --git a/src/lembas/services/agent/terminal.py b/src/lembas/services/agent/terminal.py index f4983e1..a69c76c 100644 --- a/src/lembas/services/agent/terminal.py +++ b/src/lembas/services/agent/terminal.py @@ -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, diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py index 386cdf8..582ef19 100644 --- a/src/lembas/services/settings_store.py +++ b/src/lembas/services/settings_store.py @@ -88,6 +88,11 @@ def _agents_defaults() -> dict[str, Any]: # SSH connection held open, so this is a real resource, not a scruple. "terminal_max_sessions": 20, "terminal_max_per_user": 3, + # Whether the panel's shell is given hooks that mark where one + # command ends and the next begins. Off means the Copy and Send + # buttons fall back to scraping the screen, and Auto is + # unavailable -- there is nothing to key it on. + "terminal_integration": True, # A listing of the project directory, put in front of the model so the # first rounds of a reply are not spent discovering what is there. It # costs its budget on *every* request in an agent chat, forever, which diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index 2f1a364..af5d06c 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -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) { diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 5b43892..49b4c56 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -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 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); diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js index 9e8f15c..74edc7a 100644 --- a/src/lembas/web/static/js/terminal.js +++ b/src/lembas/web/static/js/terminal.js @@ -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); } }); diff --git a/src/lembas/web/templates/admin/agents.html b/src/lembas/web/templates/admin/agents.html index 499eabf..1066ead 100644 --- a/src/lembas/web/templates/admin/agents.html +++ b/src/lembas/web/templates/admin/agents.html @@ -222,6 +222,22 @@ One per chat. Each holds an SSH connection open on the far machine.

+ +
+ +

+ Gives bash and zsh the same invisible markers VS Code and WezTerm use, + so Copy, Send 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. +

+
diff --git a/src/lembas/web/templates/base.html b/src/lembas/web/templates/base.html index 508764f..cf299dd 100644 --- a/src/lembas/web/templates/base.html +++ b/src/lembas/web/templates/base.html @@ -1,5 +1,5 @@ - + @@ -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 */ } })(); diff --git a/src/lembas/web/templates/chat/_terminal.html b/src/lembas/web/templates/chat/_terminal.html index c55e9e4..49ee2cc 100644 --- a/src/lembas/web/templates/chat/_terminal.html +++ b/src/lembas/web/templates/chat/_terminal.html @@ -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. + #} + +

{{ icon("terminal", "icon--sm") }} {{ agent_profile.name if agent_profile else "Terminal" }} {{ chat.project_dir }}

+ {# + 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. + #} + +