Terminal: a shell on the agent chat's computer, before the first message too
The Terminal panel opens a shell on the device an agent chat works on, in its
folder: for a new agent chat as soon as the device and folder are chosen (the
server's /api/v2/devices/{device}/terminal/ws?directory=, which the chat's first
message takes over -- the same socket carries on), and for an open agent chat
from the header (/api/v2/chats/{chat}/terminal/ws, the web page's shell).
Offered where GET /devices says the device opens a terminal.
The socket is Rust's (src-tauri/src/terminal.rs, tungstenite on the rustls
ureq already brings): the bearer token goes in the handshake and never reaches
the page, which gets the frames as `terminal` events and sends keys, sizes and
close through commands. 4401 signs the app out as a 401 does.
xterm and its fit addon are the server's vendored build, pinned through
fetch-vendor.sh like CodeMirror and loaded on the first open. Send to chat puts
the selection, the last marked command and its output, or the screen into the
composer, never sent. The terminal and the activity panel take turns in the
panel column. Continue in a terminal stays in the chat's menu.
tests/ui: seven B3 cases with a mocked socket, at 1280 and 390 px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
21 files changed
+1989
-24
No files matched your search
@@ -0,0 +1,491 @@
|
||||
// The terminal panel: a shell on the device an agent chat works on, beside the chat -- the web
|
||||
// page's panel (the server's static/js/terminal.js), in this app. Two places it opens:
|
||||
//
|
||||
// - an open agent chat: the chat's own shell (`{ chat }`), the one the web page shares;
|
||||
// - a new agent chat, as soon as its device and folder are chosen (`{ device, directory }`):
|
||||
// a shell there before the first message, which the chat that message makes takes over.
|
||||
//
|
||||
// The socket is Rust's (bridge.js `openTerminal`): the page never holds the token. xterm is the
|
||||
// server's vendored build (ui/vendor, scripts/fetch-vendor.sh), loaded the first time the panel
|
||||
// opens. Never beside the activity panel: the shell's layout has one panel column (as the web
|
||||
// page has), so opening one closes the other -- the shell stays open behind it.
|
||||
//
|
||||
// Three things about xterm the web panel learnt the hard way, kept here: it cannot fit while its
|
||||
// box is hidden (so every fit waits until the panel is shown), the panel changes size without a
|
||||
// window `resize` (so a ResizeObserver), and it reads no CSS variables (so the theme is read from
|
||||
// the computed tokens, and again when the theme changes).
|
||||
|
||||
import { $, h, icon, ICONS } from "./dom.js"
|
||||
import { openTerminal, why } from "./bridge.js"
|
||||
|
||||
const ANSI = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
|
||||
const MAX_MARKED = 200
|
||||
|
||||
let ctx = { layout: () => {}, composer: () => {} }
|
||||
let target = null // what the panel is for: { key, chat } | { key, device, directory }, with `name`
|
||||
let term = null
|
||||
let fit = null
|
||||
let session = null // the open socket's handle, and which target it is for: { handle, key, draft }
|
||||
let observer = null
|
||||
let loading = null
|
||||
// A device's shell marks its own commands (OSC 133 A/B/C/D, OSC 7 for where it is) and says so
|
||||
// in its ready frame (`osc`): then a command and its output are read from the panel's own
|
||||
// buffer. Without the marks, "send" falls back to the screen as it is, and says so.
|
||||
let osc = false
|
||||
let pending = null
|
||||
let finished = []
|
||||
let visiting = -1
|
||||
|
||||
const panel = () => $("terminal")
|
||||
|
||||
function say(text, isError) {
|
||||
const el = $("terminal-message")
|
||||
el.textContent = text || ""
|
||||
el.classList.toggle("is-error", !!isError)
|
||||
}
|
||||
|
||||
/** Whether the panel is shown. */
|
||||
export function terminalOpen() {
|
||||
return !panel().hidden
|
||||
}
|
||||
|
||||
/** Whether there is a shell to open for what the chat page shows now. */
|
||||
export function terminalAvailable() {
|
||||
return !!target
|
||||
}
|
||||
|
||||
// --- xterm, loaded once --------------------------------------------------------------------------
|
||||
|
||||
function load() {
|
||||
if (loading) return loading
|
||||
loading = new Promise((resolve, reject) => {
|
||||
if (typeof window.Terminal === "function" && window.FitAddon) return resolve()
|
||||
document.head.append(h("link", { rel: "stylesheet", href: "vendor/xterm.css" }))
|
||||
const script = (src) =>
|
||||
new Promise((ok, fail) => {
|
||||
const s = h("script", { src })
|
||||
s.addEventListener("load", ok)
|
||||
s.addEventListener("error", () => fail(new Error(src)))
|
||||
document.head.append(s)
|
||||
})
|
||||
script("vendor/xterm.js")
|
||||
.then(() => script("vendor/xterm-addon-fit.js"))
|
||||
.then(resolve, reject)
|
||||
})
|
||||
loading.catch(() => {
|
||||
loading = null
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
function readTheme() {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const token = (name, fallback) => (style.getPropertyValue(name) || "").trim() || fallback
|
||||
const theme = {
|
||||
background: token("--code-bg", "black"),
|
||||
foreground: token("--ink", "white"),
|
||||
cursor: token("--accent", "white"),
|
||||
cursorAccent: token("--code-bg", "black"),
|
||||
selectionBackground: token("--accent-soft", "gray"),
|
||||
}
|
||||
for (const name of ANSI) {
|
||||
const value = token(`--ansi-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, "")
|
||||
if (value) theme[name] = value
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
/** The theme changed: xterm's colours are read again. */
|
||||
export function terminalThemeChanged() {
|
||||
if (term) term.options.theme = readTheme()
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (term) return
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
term = new window.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
|
||||
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
|
||||
scrollback: 5000,
|
||||
theme: readTheme(),
|
||||
})
|
||||
fit = new window.FitAddon.FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open($("terminal-screen"))
|
||||
term.parser.registerOscHandler(133, onCommandMark)
|
||||
term.parser.registerOscHandler(7, onWhere)
|
||||
term.onData((data) => session?.handle?.input(data))
|
||||
// Ctrl+C is the shell's interrupt; copy and paste are the shifted pair, as in every terminal.
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (!e.ctrlKey || !e.shiftKey || e.type !== "keydown") return true
|
||||
if (osc && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||
visit(e.key === "ArrowUp" ? -1 : 1)
|
||||
return false
|
||||
}
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === "c") {
|
||||
const text = term.getSelection()
|
||||
if (text) navigator.clipboard.writeText(text).catch(() => {})
|
||||
return false
|
||||
}
|
||||
if (key === "v") {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => session?.handle?.input(text))
|
||||
.catch(() => {})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
observer = new ResizeObserver(() => refit())
|
||||
observer.observe($("terminal-screen"))
|
||||
}
|
||||
|
||||
function refit() {
|
||||
if (!term || !fit || !terminalOpen() || !$("terminal-screen").offsetWidth) return
|
||||
try {
|
||||
fit.fit()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
session?.handle?.resize(term.cols, term.rows)
|
||||
}
|
||||
|
||||
// --- The socket ------------------------------------------------------------------------------------
|
||||
|
||||
function decode(b64) {
|
||||
const raw = atob(b64)
|
||||
const bytes = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!target || !term || session) return
|
||||
const t0 = target
|
||||
// The session exists before the socket answers, so frames that come first are this one's.
|
||||
const mine = { handle: null, key: t0.key, draft: !t0.chat }
|
||||
session = mine
|
||||
say(t("Connecting…"))
|
||||
let handle
|
||||
try {
|
||||
handle = await openTerminal(t0.chat ? { chat: t0.chat } : { device: t0.device, directory: t0.directory }, term.cols || 80, term.rows || 24, {
|
||||
onOutput: (b64) => {
|
||||
if (session === mine) term.write(decode(b64))
|
||||
},
|
||||
onControl: (frame) => {
|
||||
if (session === mine) control(frame)
|
||||
},
|
||||
onEnd: (end) => {
|
||||
if (session !== mine) return
|
||||
session = null
|
||||
ended(end)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (session === mine) session = null
|
||||
say(why(err), true)
|
||||
return
|
||||
}
|
||||
mine.handle = handle
|
||||
// Let go of meanwhile (another chat, the panel's target changed): closed at once.
|
||||
if (session !== mine) handle.close()
|
||||
}
|
||||
|
||||
function control(frame) {
|
||||
if (frame.t === "ready") {
|
||||
say(frame.shared ? t("Connected. This shell is also open in another tab, and they share a size.") : t("Connected."))
|
||||
if (frame.dir) $("terminal-where").textContent = frame.dir
|
||||
osc = !!frame.osc
|
||||
showMarkControls()
|
||||
showLast("")
|
||||
refit()
|
||||
if (terminalOpen()) term.focus()
|
||||
} else if (frame.t === "behind") {
|
||||
// This window stopped reading and was let go so the others kept up. The scrollback is the
|
||||
// state: connecting again costs nothing.
|
||||
say(frame.message || t("Reconnecting…"))
|
||||
drop()
|
||||
term.reset()
|
||||
forgetMarks()
|
||||
connect()
|
||||
} else if (frame.t === "closed" || frame.t === "error") {
|
||||
// No reconnect of its own: a new shell has lost the folder, the environment and the
|
||||
// half-typed command, and quietly putting one in its place is worse than saying it went.
|
||||
say(frame.message || t("This terminal closed."), frame.t === "error")
|
||||
drop()
|
||||
}
|
||||
}
|
||||
|
||||
function ended(end) {
|
||||
if (end.code === 4401 || end.status === 401) say(t("Sign in again: the server no longer accepts this app's sign-in."), true)
|
||||
else if (end.status === 404) say(t("The terminal needs a newer LLeMbas server. Ask its administrator to update it."), true)
|
||||
else if (end.error) say(end.error, true)
|
||||
else say(t("Disconnected. Close and reopen to reconnect."))
|
||||
}
|
||||
|
||||
/** Let go of the socket (the shell on the device lives on, as the server keeps it). */
|
||||
function drop() {
|
||||
const s = session
|
||||
session = null
|
||||
s?.handle?.close()
|
||||
}
|
||||
|
||||
// --- What the panel is for -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tell the panel what the chat page shows: `{ chat, name, directory }` for an open agent chat,
|
||||
* `{ device, directory, name }` for a new one with its device and folder chosen, or null (no shell
|
||||
* to open). A new chat's shell becomes its chat's when the first message has made the chat: the
|
||||
* server took that shell over, so the socket stays as it is.
|
||||
*/
|
||||
export function aimTerminal(next) {
|
||||
const key = next ? (next.chat ? `chat:${next.chat}` : `device:${next.device}:${next.directory}`) : ""
|
||||
const before = target
|
||||
target = next ? { ...next, key } : null
|
||||
if (target) {
|
||||
$("terminal-title").textContent = target.name || t("Terminal")
|
||||
$("terminal-where").textContent = target.directory || ""
|
||||
$("terminal-where").title = target.directory || ""
|
||||
}
|
||||
if (before?.key === key) return
|
||||
// The draft's shell is the new chat's now: the same socket, said for the chat from here on.
|
||||
const adopted = session?.draft && before && !before.chat && target?.chat && before.directory === target.directory && before.device === target.device
|
||||
if (adopted) {
|
||||
session.key = key
|
||||
session.draft = false
|
||||
return
|
||||
}
|
||||
if (session) {
|
||||
drop()
|
||||
if (term) term.reset()
|
||||
forgetMarks()
|
||||
say("")
|
||||
}
|
||||
if (!target) {
|
||||
if (terminalOpen()) showTerminal(false)
|
||||
return
|
||||
}
|
||||
if (terminalOpen()) connect()
|
||||
}
|
||||
|
||||
// --- Showing it ------------------------------------------------------------------------------------
|
||||
|
||||
/** Show or hide the panel. Hiding keeps the shell and its socket: reopening is instant. */
|
||||
export async function showTerminal(on) {
|
||||
if (on && !target) return
|
||||
panel().hidden = !on
|
||||
ctx.layout(on)
|
||||
markOpen()
|
||||
if (!on) return
|
||||
try {
|
||||
await load()
|
||||
} catch {
|
||||
say(t("The terminal could not be loaded."), true)
|
||||
return
|
||||
}
|
||||
if (!terminalOpen()) return
|
||||
build()
|
||||
// A frame later: the panel has only just been shown and has no width to fit to yet.
|
||||
requestAnimationFrame(() => {
|
||||
refit()
|
||||
connect()
|
||||
term.focus()
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleTerminal() {
|
||||
showTerminal(!terminalOpen())
|
||||
}
|
||||
|
||||
function markOpen() {
|
||||
for (const b of document.querySelectorAll("[data-terminal-toggle]")) b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||
}
|
||||
|
||||
/** The header's button for an open chat; a new chat has the chip under the composer instead. */
|
||||
export function terminalButton() {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "tool",
|
||||
"aria-label": t("Terminal"),
|
||||
title: t("Terminal"),
|
||||
"aria-controls": "terminal",
|
||||
"aria-pressed": String(terminalOpen()),
|
||||
"data-terminal-toggle": "",
|
||||
onclick: toggleTerminal,
|
||||
},
|
||||
icon(ICONS.terminal),
|
||||
)
|
||||
}
|
||||
|
||||
// --- A device shell's own marks ----------------------------------------------------------------------
|
||||
|
||||
function onCommandMark(data) {
|
||||
if (!osc || !term) return false
|
||||
const [kind, code] = String(data).split(";")
|
||||
if (kind === "A") pending = { prompt: term.registerMarker(0) }
|
||||
else if (kind === "B") {
|
||||
pending = pending || {}
|
||||
pending.input = term.registerMarker(0)
|
||||
pending.inputX = term.buffer.active.cursorX
|
||||
} else if (kind === "C") {
|
||||
pending = pending || {}
|
||||
pending.output = term.registerMarker(0)
|
||||
pending.command = commandText(pending)
|
||||
} else if (kind === "D") {
|
||||
if (pending?.output) {
|
||||
pending.exit = parseInt(code, 10) || 0
|
||||
pending.end = term.registerMarker(0)
|
||||
commandDone(pending)
|
||||
}
|
||||
pending = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onWhere(data) {
|
||||
if (!osc) return false
|
||||
let path = String(data).replace(/^file:\/\/[^/]*/, "")
|
||||
try {
|
||||
path = decodeURIComponent(path)
|
||||
} catch {}
|
||||
if (path) $("terminal-where").textContent = path
|
||||
return true
|
||||
}
|
||||
|
||||
function linesBetween(from, to, firstX) {
|
||||
const buffer = term.buffer.active
|
||||
const out = []
|
||||
for (let y = from; y < to; y++) {
|
||||
const line = buffer.getLine(y)
|
||||
if (!line) continue
|
||||
const text = line.translateToString(true, y === from ? firstX || 0 : 0)
|
||||
if (line.isWrapped && out.length) out[out.length - 1] += text
|
||||
else out.push(text)
|
||||
}
|
||||
while (out.length && !out[out.length - 1].trim()) out.pop()
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
function commandText(mark) {
|
||||
if (!mark.input || !mark.output || mark.input.line < 0) return ""
|
||||
return linesBetween(mark.input.line, Math.max(mark.output.line, mark.input.line + 1), mark.inputX).trim()
|
||||
}
|
||||
|
||||
function outputOf(mark) {
|
||||
if (!mark.output || !mark.end || mark.output.line < 0 || mark.end.line < 0) return ""
|
||||
return linesBetween(mark.output.line, mark.end.line, 0)
|
||||
}
|
||||
|
||||
function commandDone(mark) {
|
||||
finished.push(mark)
|
||||
if (finished.length > MAX_MARKED) finished.shift()
|
||||
visiting = -1
|
||||
showLast((mark.command || "…") + (mark.exit ? ` · exit ${mark.exit}` : ""))
|
||||
}
|
||||
|
||||
function blockOf(mark) {
|
||||
const where = $("terminal-where").textContent
|
||||
const head = `${where ? `${where} ` : ""}$ ${mark.command || ""}`
|
||||
return "```\n" + head + "\n" + outputOf(mark) + "\n```\n" + (mark.exit ? `exit ${mark.exit}\n` : "")
|
||||
}
|
||||
|
||||
function lastFinished() {
|
||||
for (let i = finished.length - 1; i >= 0; i--) if (finished[i].output && finished[i].output.line >= 0) return finished[i]
|
||||
return null
|
||||
}
|
||||
|
||||
function visit(step) {
|
||||
const live = finished.filter((m) => (m.prompt || m.input || m.output)?.line >= 0)
|
||||
if (!live.length) return
|
||||
visiting = visiting < 0 ? live.length : visiting
|
||||
visiting = Math.max(0, Math.min(live.length - 1, visiting + step))
|
||||
const m = live[visiting]
|
||||
term.scrollToLine((m.prompt || m.input || m.output).line)
|
||||
}
|
||||
|
||||
function forgetMarks() {
|
||||
finished = []
|
||||
pending = null
|
||||
visiting = -1
|
||||
osc = false
|
||||
showMarkControls()
|
||||
showLast("")
|
||||
}
|
||||
|
||||
function showMarkControls() {
|
||||
$("terminal-prev").hidden = !osc
|
||||
$("terminal-next").hidden = !osc
|
||||
}
|
||||
|
||||
function showLast(text) {
|
||||
$("terminal-last").textContent = text || ""
|
||||
}
|
||||
|
||||
// --- Handing it to the chat --------------------------------------------------------------------------
|
||||
|
||||
/** What "send" and "copy" take: the selection; else the last command (with marks); else the screen. */
|
||||
function capture() {
|
||||
if (!term) return ""
|
||||
const chosen = term.getSelection()
|
||||
if (chosen && chosen.trim()) return "```\n" + chosen + "\n```\n"
|
||||
if (osc) {
|
||||
const mark = lastFinished()
|
||||
if (!mark) {
|
||||
say(t("Nothing has been run in this shell yet."))
|
||||
return ""
|
||||
}
|
||||
return blockOf(mark)
|
||||
}
|
||||
const buffer = term.buffer.active
|
||||
const last = buffer.baseY + buffer.cursorY
|
||||
const lines = []
|
||||
for (let y = Math.max(0, last - 40); y <= last; y++) {
|
||||
const line = buffer.getLine(y)
|
||||
if (line) lines.push(line.translateToString(true))
|
||||
}
|
||||
const text = lines.join("\n").replace(/\n+$/, "")
|
||||
if (!text.trim()) {
|
||||
say(t("Nothing to send: select some output first."))
|
||||
return ""
|
||||
}
|
||||
say(t("Copied the last of the screen, as it appeared. This shell does not mark where commands begin."))
|
||||
return "```\n" + text + "\n```\n"
|
||||
}
|
||||
|
||||
/** Into the message box, never sent: what a machine printed is for the person to read first. */
|
||||
function sendToChat() {
|
||||
const text = capture()
|
||||
if (!text) return
|
||||
ctx.composer(text)
|
||||
// Where the panel covers the chat (a narrow window), it steps aside to show the message box.
|
||||
if (window.matchMedia("(max-width: 64rem)").matches) showTerminal(false)
|
||||
}
|
||||
|
||||
function copyOut() {
|
||||
const text = capture()
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => say(t("Copied.")),
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
|
||||
export function initTerminal({ onLayout, composer }) {
|
||||
ctx = { layout: onLayout || (() => {}), composer: composer || (() => {}) }
|
||||
$("terminal-icon").replaceChildren(icon(ICONS.terminal))
|
||||
$("terminal-prev").replaceChildren(icon(ICONS.chevronUp))
|
||||
$("terminal-next").replaceChildren(icon(ICONS.chevronDown))
|
||||
$("terminal-copy").replaceChildren(icon(ICONS.copy))
|
||||
$("terminal-send").prepend(icon(ICONS.send))
|
||||
$("terminal-close").replaceChildren(icon(ICONS.close))
|
||||
$("terminal-prev").addEventListener("click", () => visit(-1))
|
||||
$("terminal-next").addEventListener("click", () => visit(1))
|
||||
$("terminal-copy").addEventListener("click", copyOut)
|
||||
$("terminal-send").addEventListener("click", sendToChat)
|
||||
$("terminal-close").addEventListener("click", () => showTerminal(false))
|
||||
}
|
||||
Reference in new issue
Block a user