Terminal: a shell on the agent chat's computer, before the first message too
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s

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:
HomerandClaude Opus 5.5 committed 2026-11-10 09:06:01 +00:00
1 parent ec32bca644
commit ac0d4ce623
21 files changed
+1989 -24

No files matched your search

+40
View File
@@ -10,12 +10,18 @@
// not in the table is a 404, as an older server would answer.
// streams { "/chats/c1/messages/m1/stream": [[event, data], ...] } -- the frames a followed
// reply sends as soon as it is opened. More can be sent later with `__mock.frame()`.
// terminal [[kind, data], ...] -- what every terminal's socket sends as soon as it is opened
// (`control` frames as the server's JSON, `output` as text, sent base64 as Rust does).
// More with `__mock.shell(kind, data)`; `{ refuse: {status, detail} }` makes
// `terminal_open` fail.
//
// Nothing here talks to a network: the page's policy allows it none, and this keeps it so.
;(function () {
const config = window.__mockConfig || {}
const listeners = new Map() // event name -> [callback]
const streams = new Map() // stream id -> path
const terminals = new Map() // terminal id -> { target, open }
let nextTerminal = 0
let nextStream = 0
const calls = []
@@ -36,6 +42,17 @@
return Promise.reject({ status: 404, detail: `Not in the test's table: ${method} ${path}` })
}
// The shell's bytes, base64 as Rust sends them (`terminal.rs`): UTF-8 first.
const b64 = (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text)))
function shell(kind, data, id) {
const ids = id ? [id] : [...terminals.keys()].filter((k) => terminals.get(k).open)
for (const tid of ids) {
if (kind === "end") terminals.get(tid).open = false
dispatch("terminal", { id: tid, kind, data: kind === "output" ? b64(data) : data })
}
}
function frame(path, event, data) {
for (const [id, p] of streams) if (p === path) dispatch("stream", { id, event, data })
}
@@ -61,6 +78,24 @@
case "stream_close":
streams.delete(args.id)
return null
case "terminal_open": {
const t = config.terminal || []
if (t.refuse) return Promise.reject(t.refuse)
const id = ++nextTerminal
terminals.set(id, { target: { chat: args.chat, device: args.device, directory: args.directory }, open: true })
setTimeout(() => {
for (const [kind, data] of t) shell(kind, data, id)
}, 0)
return id
}
case "terminal_input":
case "terminal_resize":
return !!terminals.get(args.id)?.open
case "terminal_close": {
const t = terminals.get(args.id)
if (t?.open) setTimeout(() => shell("end", { code: 1000, reason: "", status: null, error: null }, args.id), 0)
return null
}
case "model_image":
return ""
case "terminal_word":
@@ -96,5 +131,10 @@
/** The calls of one command, or of `api` with one method and path prefix. */
api: (method, prefix) => calls.filter((c) => c.cmd === "api" && c.args.method === method && c.args.path.startsWith(prefix)),
streams: () => [...streams.values()],
shell,
/** The terminals opened so far: [{ id, target, open }]. */
terminals: () => [...terminals].map(([id, t]) => ({ id, ...t })),
/** What was typed into a terminal, joined. */
typed: () => calls.filter((c) => c.cmd === "terminal_input").map((c) => c.args.data).join(""),
}
})()
+183 -1
View File
@@ -175,7 +175,7 @@ function baseRoutes(over = {}) {
return {
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {} },
"GET /models": models(),
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", linked: true }] },
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }] },
"GET /chats": { chats: [chatRow(), agentChat()], more: false },
"GET /schedules": { schedules: [] },
...over,
@@ -433,6 +433,188 @@ test("A5: an open chat follows its own `subagents`: offered, offered with the no
}
})
// --- B3: the Terminal panel -------------------------------------------------------------------
//
// The server's two sockets for an app (`api/v2/terminal.py`): an agent chat's
// (`/chats/{chat}/terminal/ws`) and one before the chat exists, on the device and folder chosen
// (`/devices/{device}/terminal/ws?directory=`), which the chat's first message takes over.
// `GET /devices` says per device whether it opens a terminal. The socket is Rust's: the page asks
// `terminal_open` and gets the frames as `terminal` events (mock-tauri.js plays the server).
const READY = ["control", { t: "ready", label: "workbench", dir: "/home/dev/project", cols: 80, rows: 24, shared: false, osc: false }]
function b3(over = {}, terminal = [READY, ["output", "dev@workbench:~/project$ "]]) {
return {
status: status(),
routes: baseRoutes({
"GET /chats/chat2": { chat: agentChat({ running: false }), messages: [userMessage("m1", "What is in build?")], more: false, live: null },
"GET /models?chat=chat2": models(),
"POST /chats": { chat: agentChat({ running: false }), message: userMessage("m9", "Build it"), reply: null },
...over,
}),
streams: {},
terminal,
}
}
async function screenText(p) {
return p.evaluate(() => document.querySelector("#terminal .xterm-rows")?.textContent || "")
}
test("B3: a new agent chat offers the Terminal once its folder is chosen, and opens a shell there before the first message", async () => {
const p = await page(b3())
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip--terminal")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
await p.click("#composer-chips .cchip--terminal")
await p.waitForSelector("#terminal:not([hidden]) .xterm")
await p.waitForFunction(() => window.__mock.terminals().length === 1)
const [shell] = await p.evaluate(() => window.__mock.terminals())
assert.deepEqual(shell.target, { chat: null, device: "token1", directory: "/home/dev/project" })
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
assert.equal(await p.textContent("#terminal-message"), "Connected.")
assert.equal(await p.textContent("#terminal-title"), "workbench")
assert.equal(await p.getAttribute("#composer-chips .cchip--terminal", "aria-pressed"), "true")
// Keys go up through the bridge, never a socket of the page's own.
await p.click("#terminal .xterm")
await p.keyboard.type("ls")
await p.keyboard.press("Enter")
await p.waitForFunction(() => window.__mock.typed().includes("ls\r"))
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "terminal_open").length), 1)
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-new-1280.png") })
// The first message makes the chat, which takes the shell over: the same socket goes on.
await p.click("#composer-text")
await p.fill("#composer-text", "Build it")
await p.click("#send")
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
const [made] = await p.evaluate(() => window.__mock.api("POST", "/chats"))
assert.equal(made.args.body.device, "token1")
assert.equal(made.args.body.directory, "/home/dev/project")
await p.waitForSelector('#chat-tools [data-terminal-toggle][aria-pressed="true"]')
assert.ok(await p.isVisible("#terminal"), "the panel stays open")
const shells = await p.evaluate(() => window.__mock.terminals())
assert.equal(shells.length, 1, "no second shell: the chat took the first one over")
assert.ok(shells[0].open, "and its socket is still open")
assert.deepEqual(p.errors, [])
})
test("B3: an open agent chat opens its own shell from the header; Send to chat fills the composer and sends nothing", async () => {
const p = await page(b3())
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => window.__mock.terminals().length === 1)
assert.deepEqual((await p.evaluate(() => window.__mock.terminals()))[0].target, { chat: "chat2", device: null, directory: null })
await p.evaluate(() => window.__mock.shell("output", "\r\ntotal 4\r\n-rw-r--r-- 1 dev dev 12 build.log\r\n"))
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("build.log"))
await p.fill("#composer-text", "Look at this:")
await p.click("#terminal-send")
const text = await p.inputValue("#composer-text")
assert.ok(text.startsWith("Look at this:\n\n```\n"), `after what was typed, as a block: ${JSON.stringify(text.slice(0, 40))}`)
assert.match(text, /build\.log/)
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/messages").length), 0, "nothing is sent")
// Closing the panel keeps the shell; opening it again does not open another.
await p.click("#terminal-close")
assert.ok(await p.isHidden("#terminal"))
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForSelector("#terminal:not([hidden])")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
// The server ends it: said, and no new shell in its place.
await p.evaluate(() => window.__mock.shell("control", { t: "closed", message: "The shell exited." }))
await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "The shell exited.")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
assert.deepEqual(p.errors, [])
})
test("B3: the terminal and the activity panel take turns in the panel column", async () => {
const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } }))
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForSelector("#terminal:not([hidden])")
assert.equal(await p.getAttribute("#shell", "data-terminal"), "true")
await p.click("#activity-open")
await p.waitForSelector("#activity:not([hidden])")
assert.ok(await p.isHidden("#terminal"), "the terminal gave way")
assert.equal(await p.getAttribute("#shell", "data-terminal"), "false")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForSelector("#terminal:not([hidden])")
assert.ok(await p.isHidden("#activity"), "and the activity panel to it")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "the shell lived on behind it")
})
test("B3: no Terminal where the device opens none, for a chat on the server, or before a folder", async () => {
const off = { "GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: false }] } }
let p = await page(b3(off))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal")
await openChatRow(p, "chat2")
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
await p.context().close()
p = await page(b3({}))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Chat")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell")
await p.context().close()
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet")
assert.deepEqual(p.errors, [])
})
test("B3: a refused sign-in and an older server are said in words", async () => {
let p = await page(b3({}, [["end", { code: 4401, reason: "not allowed", status: null, error: null }]]))
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => /Sign in again/.test(document.querySelector("#terminal-message").textContent))
assert.ok(await p.evaluate(() => document.querySelector("#terminal-message").classList.contains("is-error")))
await p.context().close()
p = await page(b3({}, [["end", { code: null, reason: "", status: 404, error: null }]]))
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => /needs a newer LLeMbas server/.test(document.querySelector("#terminal-message").textContent))
assert.deepEqual(p.errors, [])
})
test("B3: at 390 px the terminal is a sheet over the chat, inside the window", async () => {
const p = await page(b3(), { width: 390, height: 844 })
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
await p.waitForSelector('#chat-tools [data-terminal-toggle]')
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
const box = await p.locator("#terminal").boundingBox()
assert.ok(box.x >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844, `inside: ${JSON.stringify(box)}`)
const send = await p.locator("#terminal-send").boundingBox()
assert.ok(send.x + send.width <= 390 && send.y + send.height <= 844, `Send is reachable: ${JSON.stringify(send)}`)
const close = await p.locator("#terminal-close").boundingBox()
assert.ok(close.x + close.width <= 390, "Close is reachable")
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-390.png") })
// Send to chat: the sheet steps aside, and the message box has the block.
await p.click("#terminal-send")
assert.ok(await p.isHidden("#terminal"))
assert.match(await p.inputValue("#composer-text"), /dev@workbench/)
assert.deepEqual(p.errors, [])
})
test("B3: at 1280 px the terminal sits beside the chat, which keeps its column", async () => {
const p = await page(b3())
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
const term = await p.locator("#terminal").boundingBox()
const main = await p.locator("#main").boundingBox()
assert.ok(term.x >= main.x + main.width - 1, "beside, not over")
assert.ok(main.width >= 400, `the chat keeps room: ${main.width}`)
assert.ok(term.width >= 380, `the terminal has its width: ${term.width}`)
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-1280.png") })
assert.deepEqual(p.errors, [])
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""