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>
141 lines
5.5 KiB
JavaScript
141 lines
5.5 KiB
JavaScript
// A stand-in for Tauri's bridge, for the page under a headless browser (tests/ui/run.mjs). Runs
|
|
// before the page's own scripts: `window.__TAURI__` answers the commands the page invokes from a
|
|
// table the test hands in, and records every call, so a test can say what the page asked for.
|
|
//
|
|
// The table (`config`):
|
|
// status what `get_status` answers (the app, the agent, the server)
|
|
// routes { "GET /me": value, "POST /chats/c1/interactions/k1": value, ... } -- the server's
|
|
// /api/v2 as the page reaches it through `api`. A path is looked up with its query,
|
|
// then without it. A value `{ __error: { status, detail } }` is a refusal. Anything
|
|
// 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 = []
|
|
|
|
function dispatch(name, payload) {
|
|
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
|
|
}
|
|
|
|
function route(method, path) {
|
|
const routes = config.routes || {}
|
|
const bare = path.split("?")[0]
|
|
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
|
|
if (Object.prototype.hasOwnProperty.call(routes, key)) {
|
|
const value = routes[key]
|
|
if (value && value.__error) return Promise.reject(value.__error)
|
|
return Promise.resolve(JSON.parse(JSON.stringify(value)))
|
|
}
|
|
}
|
|
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 })
|
|
}
|
|
|
|
async function invoke(cmd, args) {
|
|
calls.push({ cmd, args: args === undefined ? null : JSON.parse(JSON.stringify(args)) })
|
|
switch (cmd) {
|
|
case "get_status":
|
|
return JSON.parse(JSON.stringify(config.status))
|
|
case "api":
|
|
return route(args.method, args.path)
|
|
case "api_text":
|
|
return route("GET", args.path)
|
|
case "stream_open": {
|
|
const id = ++nextStream
|
|
streams.set(id, args.path)
|
|
const frames = (config.streams || {})[args.path] || []
|
|
setTimeout(() => {
|
|
for (const [event, data] of frames) dispatch("stream", { id, event, data })
|
|
}, 0)
|
|
return id
|
|
}
|
|
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":
|
|
return "lembas"
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const win = {
|
|
minimize: async () => {},
|
|
toggleMaximize: async () => {},
|
|
close: async () => {},
|
|
isMaximized: async () => false,
|
|
}
|
|
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: {
|
|
listen: async (name, cb) => {
|
|
if (!listeners.has(name)) listeners.set(name, [])
|
|
listeners.get(name).push(cb)
|
|
return () => listeners.set(name, (listeners.get(name) || []).filter((x) => x !== cb))
|
|
},
|
|
},
|
|
window: { getCurrentWindow: () => win },
|
|
}
|
|
|
|
window.__mock = {
|
|
calls,
|
|
frame,
|
|
emit: dispatch,
|
|
/** 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(""),
|
|
}
|
|
})()
|