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
+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 }
|
||||
|
||||
Reference in new issue
Block a user