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
@@ -66,6 +66,61 @@ export async function follow(path, { onFrame, onEnd }) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- The terminal panel's socket -----------------------------------------------------------------
|
||||
//
|
||||
// Opened by Rust (`terminal_open`), which holds the token and the socket; the page gets the frames
|
||||
// as `terminal` events -- `output` (the shell's bytes, base64), `control` (the server's JSON), a last
|
||||
// `end` -- and sends keys, sizes and "close" through commands. As with a followed reply, a frame
|
||||
// can come before `terminal_open` has answered with its id: those wait.
|
||||
|
||||
const terminals = new Map()
|
||||
const earlyTerminal = new Map()
|
||||
listen("terminal", (e) => {
|
||||
const { id, kind, data } = e.payload
|
||||
const f = terminals.get(id)
|
||||
if (f) deliverTerminal(f, id, kind, data)
|
||||
else {
|
||||
if (!earlyTerminal.has(id)) earlyTerminal.set(id, [])
|
||||
earlyTerminal.get(id).push([kind, data])
|
||||
}
|
||||
})
|
||||
|
||||
function deliverTerminal(f, id, kind, data) {
|
||||
if (kind === "end") {
|
||||
terminals.delete(id)
|
||||
f.onEnd(data || {})
|
||||
} else if (kind === "output") f.onOutput(data)
|
||||
else if (kind === "control") f.onControl(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a shell: `target` is `{ chat }` or `{ device, directory }`; `onOutput(base64)`,
|
||||
* `onControl(frame)`, and `onEnd({ code, reason, status, error })` once. The handle sends keys
|
||||
* (`input`), a size (`resize`), and lets go (`close`; onEnd still comes).
|
||||
*/
|
||||
export async function openTerminal(target, cols, rows, { onOutput, onControl, onEnd }) {
|
||||
const id = await invoke("terminal_open", {
|
||||
chat: target.chat ?? null,
|
||||
device: target.device ?? null,
|
||||
directory: target.directory ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
const f = { onOutput, onControl, onEnd }
|
||||
terminals.set(id, f)
|
||||
for (const [kind, data] of earlyTerminal.get(id) || []) {
|
||||
if (!terminals.has(id)) break
|
||||
deliverTerminal(f, id, kind, data)
|
||||
}
|
||||
earlyTerminal.delete(id)
|
||||
return {
|
||||
id,
|
||||
input: (data) => invoke("terminal_input", { id, data }),
|
||||
resize: (c, r) => invoke("terminal_resize", { id, cols: c, rows: r }),
|
||||
close: () => invoke("terminal_close", { id }),
|
||||
}
|
||||
}
|
||||
|
||||
/** A web link, in the system's browser. */
|
||||
export function openLink(url) {
|
||||
return invoke("open_external", { url }).catch(() => {})
|
||||
|
||||
+59
-11
@@ -18,10 +18,11 @@ import { readyIds, sending, sent, discard } from "./attach.js"
|
||||
import { watchActivity, activityChanged, planList, activityButton } from "./activity.js"
|
||||
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||
import { folderName } from "./list.js"
|
||||
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||
let open = null // { chat, live, more, first }
|
||||
|
||||
export function openChatId() {
|
||||
@@ -34,6 +35,44 @@ export function onThisComputer(chat) {
|
||||
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
||||
}
|
||||
|
||||
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
|
||||
function deviceOf(id) {
|
||||
return (ctx.devices?.() || []).find((d) => d.id === id) || null
|
||||
}
|
||||
|
||||
/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */
|
||||
function draftDevice() {
|
||||
const mine = ctx.thisDevices()
|
||||
return mine.find((id) => deviceOf(id)) || mine[0] || ""
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
|
||||
* are chosen (the shell before the first message), an open agent chat on a device that opens
|
||||
* terminals, or nothing.
|
||||
*/
|
||||
function terminalTarget() {
|
||||
if (draft) {
|
||||
if (draft.kind !== "agent" || !draft.dir) return null
|
||||
const device = deviceOf(draftDevice())
|
||||
if (!device?.terminal) return null
|
||||
return { device: device.id, directory: draft.dir, name: device.name || t("This computer") }
|
||||
}
|
||||
const chat = open?.chat
|
||||
if (chat?.kind !== "agent" || !chat.device) return null
|
||||
const device = deviceOf(chat.device.token_id)
|
||||
if (!device?.terminal) return null
|
||||
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
|
||||
}
|
||||
|
||||
/** Text into the message box, after what is there, for the person to read before sending. */
|
||||
export function intoComposer(text) {
|
||||
const box = $("composer-text")
|
||||
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
|
||||
box.dispatchEvent(new Event("input"))
|
||||
box.focus()
|
||||
}
|
||||
|
||||
function nearBottom() {
|
||||
const th = $("thread")
|
||||
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
||||
@@ -578,15 +617,9 @@ function renderHead(chat) {
|
||||
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
||||
),
|
||||
)
|
||||
if (chat.device_session_id) {
|
||||
tools.append(
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
||||
icon(ICONS.terminal),
|
||||
),
|
||||
)
|
||||
}
|
||||
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
|
||||
// "Continue in a terminal" stays in the chat's menu.
|
||||
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||
}
|
||||
tools.append(activityButton())
|
||||
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||
@@ -751,6 +784,8 @@ async function renderChips() {
|
||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||
}
|
||||
const shell = terminalTarget()
|
||||
aimTerminal(shell)
|
||||
parts.push(modelChip(models, draft.model, (id) => {
|
||||
draft.model = id
|
||||
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
||||
@@ -758,10 +793,19 @@ async function renderChips() {
|
||||
}, mayPick))
|
||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||
// Last, so the chat's own settings come first where the line scrolls.
|
||||
if (shell) {
|
||||
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
||||
b.dataset.terminalToggle = ""
|
||||
b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||
b.setAttribute("aria-controls", "terminal")
|
||||
parts.push(b)
|
||||
}
|
||||
box.replaceChildren(...parts.filter(Boolean))
|
||||
return
|
||||
}
|
||||
const chat = open?.chat
|
||||
aimTerminal(terminalTarget())
|
||||
if (!chat?.kind) return box.replaceChildren()
|
||||
const save = async (body) => {
|
||||
try {
|
||||
@@ -809,6 +853,10 @@ export function statusChanged() {
|
||||
if (draft) {
|
||||
renderChips()
|
||||
renderHello()
|
||||
} else if (open?.chat?.kind) {
|
||||
// The devices were read again: whether this chat's opens a terminal may have changed.
|
||||
renderHead(open.chat)
|
||||
aimTerminal(terminalTarget())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,7 +1140,7 @@ async function createChat(text, attachments) {
|
||||
const agents = draft.agents
|
||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||
if (draft.kind === "agent") {
|
||||
const device = ctx.thisDevices()[0]
|
||||
const device = draftDevice()
|
||||
if (!device || !agentReady(ctx.status())) {
|
||||
renderNotice()
|
||||
throw { detail: agentState(ctx.status()).sentence }
|
||||
|
||||
@@ -76,6 +76,7 @@ export const ICONS = {
|
||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
||||
chevronUp: "M3.5 10L8 5.5 12.5 10 11.4 11.1 8 7.7 4.6 11.1z",
|
||||
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
||||
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
||||
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
||||
|
||||
+19
-3
@@ -12,7 +12,8 @@ import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged } from "./chat.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal, terminalThemeChanged } from "./terminal.js"
|
||||
import { initAttach, allowAttach } from "./attach.js"
|
||||
import { initPalette, openPalette } from "./palette.js"
|
||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||
@@ -67,6 +68,7 @@ function applyTheme(name) {
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = name
|
||||
root.dataset.base = name
|
||||
terminalThemeChanged()
|
||||
}
|
||||
|
||||
// --- Views ------------------------------------------------------------------------------------
|
||||
@@ -162,7 +164,10 @@ function showPage(name) {
|
||||
$("page-scheduled").hidden = name !== "scheduled"
|
||||
$("settings").hidden = name !== "settings"
|
||||
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||
if (name !== "chat") showPanel(false)
|
||||
if (name !== "chat") {
|
||||
showPanel(false)
|
||||
showTerminal(false)
|
||||
}
|
||||
if (narrow()) sideOpen(false)
|
||||
}
|
||||
|
||||
@@ -221,6 +226,7 @@ function commands() {
|
||||
{ label: t("Settings"), run: () => showSettings(true) },
|
||||
]
|
||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
|
||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||
out.push(
|
||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
|
||||
@@ -265,6 +271,7 @@ async function boot() {
|
||||
initChat({
|
||||
status: () => status,
|
||||
thisDevices,
|
||||
devices: () => devices,
|
||||
models: () => models,
|
||||
me: () => me,
|
||||
changed: () => refreshList(),
|
||||
@@ -289,9 +296,18 @@ async function boot() {
|
||||
onViewer((on) => {
|
||||
$("shell").dataset.viewer = String(on)
|
||||
})
|
||||
// One panel column: the activity panel and the terminal take turns in it (the shell stays open).
|
||||
initTerminal({
|
||||
onLayout: (on) => {
|
||||
$("shell").dataset.terminal = String(on)
|
||||
if (on && panelOpen()) showPanel(false)
|
||||
},
|
||||
composer: intoComposer,
|
||||
})
|
||||
initActivity({
|
||||
onLayout: (on) => {
|
||||
$("shell").dataset.activity = String(on)
|
||||
if (on && terminalOpen()) showTerminal(false)
|
||||
},
|
||||
here: onThisComputer,
|
||||
// The conversation as it now stands: the summary's divider, read again, if it is still open.
|
||||
@@ -353,7 +369,7 @@ async function boot() {
|
||||
renderBar(status)
|
||||
route()
|
||||
// The agent came up or went: which devices are linked changes with it.
|
||||
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
|
||||
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(() => (refreshList(), statusChanged()))
|
||||
})
|
||||
listen("open-settings", () => {
|
||||
if (view === "app") showSettings(true)
|
||||
|
||||
@@ -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