feat(tui): expose open_terminal over client MCP (experimental) (#57506)

This commit is contained in:
James Long
2026-10-22 16:16:28 -04:00
committed by GitHub
Unverified
parent 4a6dacec5e
commit 0b93f0b749
8 changed files with 410 additions and 10 deletions
+11 -3
View File
@@ -108,6 +108,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { ClientMcp } from "./context/client-mcp"
import { PanelProvider, usePanel } from "./context/panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -213,9 +214,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
terminalSuspend: process.platform !== "win32",
})
const headers = Service.headers(input.server.endpoint)
const options = {
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
headers,
fetch: noTimeoutFetch,
}
const api = OpenCode.make(options)
@@ -232,7 +234,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint), fetch: noTimeoutFetch }
return { api: OpenCode.make(next), url: endpoint.url }
return { api: OpenCode.make(next), url: endpoint.url, headers: next.headers }
},
restart: managed.restart,
}
@@ -397,7 +399,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
<ClientProvider
api={api}
url={input.server.endpoint.url}
headers={headers}
service={service}
>
<PermissionProvider>
<DataProvider directory={directory}>
<LocationProvider>
@@ -408,6 +415,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<ClientMcp />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
@@ -20,6 +20,11 @@ export const experiments: Experiment[] = [
title: "New designs",
description: "Preview the new TUI designs",
},
{
id: "client-mcp",
title: "TUI tools",
description: "Let the agent open terminals in this TUI (requires server support)",
},
]
export function DialogExperiments() {
@@ -4,6 +4,7 @@ import { extend, useRenderer } from "@opentui/solid"
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { useTheme, useThemes } from "../context/theme"
import { errorMessage } from "../util/error"
@@ -35,6 +36,7 @@ export function TerminalPane(props: {
const theme = useTheme()
const themes = useThemes()
const renderer = useRenderer()
const terminals = useSessionTerminals()
const [failure, setFailure] = createSignal<string>()
const attachmentID = crypto.randomUUID()
const stream: StreamItem[] = []
@@ -90,6 +92,8 @@ export function TerminalPane(props: {
if (size) send(interactionFrame(size, data))
}
onCleanup(terminals.attachWriter(props.ptyID, sendInput))
const processStream = () => {
if (disposed || !terminal || !sameSize(canonicalSize, terminalSize)) return
while (stream.length > 0) {
+6
View File
@@ -385,3 +385,9 @@ export function useNewDesigns() {
const config = useConfig()
return () => config.data.experimental?.["new-designs"] === true
}
/** Reactive accessor for the temporary "client-mcp" experiment. */
export function useClientMcpExperiment() {
const config = useConfig()
return () => config.data.experimental?.["client-mcp"] === true
}
+119
View File
@@ -0,0 +1,119 @@
import { connectClientMcp, type ClientMcpConnection } from "@opencode/client/mcp"
import { createEffect, onCleanup, untrack } from "solid-js"
import { useClientMcpExperiment } from "../config"
import { useClient } from "./client"
import { useData } from "./data"
import { useLocation } from "./location"
import { useRoute } from "./route"
import { useSessionTabs } from "./session-tabs"
import { useSessionTerminals } from "./session-terminals"
// Serves TUI tools to the server as the client MCP server "tui", so the model sees them as `tui_<tool>`.
export function ClientMcp() {
const client = useClient()
const enabled = useClientMcpExperiment()
const data = useData()
const location = useLocation()
const route = useRoute()
const tabs = useSessionTabs()
const terminals = useSessionTerminals()
const state: { key?: string; connection?: ClientMcpConnection } = {}
const openTerminal = async (calledBy: string | undefined) => {
const result = await terminalFor(calledBy)
if (typeof result !== "string") return result
return { content: [{ type: "text" as const, text: `Opened terminal ${result}` }] }
}
const writeTerminal = async (calledBy: string | undefined, text: unknown) => {
if (typeof text !== "string") return failure("text must be a string")
const result = await terminalFor(calledBy)
if (typeof result !== "string") return result
terminals.write(result, new TextEncoder().encode(text))
return { content: [{ type: "text" as const, text: `Wrote to terminal ${result}` }] }
}
// Resolves the terminal open_terminal shows (selected, else newest, else a new one), or a tool failure.
const terminalFor = async (calledBy: string | undefined) => {
if (!calledBy) return failure("Unknown session")
if (!data.session.get(calledBy)) await data.session.sync(calledBy).catch(() => undefined)
if (!data.session.get(calledBy)) return failure("Unknown session")
await terminals.checked()
if (!terminals.available()) return failure("This server does not support terminals")
const root = data.session.root(calledBy)
const routed = route.data.type === "session" ? route.data.sessionID : undefined
// Terminal panes render for the routed session; tabs hold root sessions, which become routed when selected.
const target =
routed && data.session.root(routed) === root
? routed
: tabs.enabled() && tabs.tabs().some((tab) => tab.sessionID === root)
? root
: undefined
if (!target) return failure("session not open in this TUI")
if (!data.session.get(target)) await data.session.sync(target).catch(() => undefined)
await terminals.refresh(target)
// The agent opened it, so don't move the user's keyboard focus into a live shell.
const current = terminals.get(target)
const existing = current.selectedTerminalID ?? current.terminals.at(-1)?.id
if (existing && existing !== current.selectedTerminalID)
await terminals.selectTerminal(target, existing, { focus: false })
return existing ?? (await terminals.newTerminal(target, { focus: false })).id
}
// Reconnects when the flag flips, the viewed location changes, or the server endpoint changes after a service reconnect.
// Server MCP is Location-scoped, so only sessions in the viewed location's directory see the tool.
const sync = () => {
const url = client.url
const directory = location.ref?.directory ?? data.location.default().directory
const key = enabled() && url ? JSON.stringify([url, client.headers, directory]) : undefined
if (key === state.key) return
void state.connection?.close()
state.key = key
state.connection =
key && url
? untrack(() =>
connectClientMcp({
url,
name: "tui",
directory,
headers: client.headers,
tools: [
{
name: "open_terminal",
description:
"Open a terminal for the current session in the user's TUI, without taking keyboard focus.",
inputSchema: { type: "object" },
handler: (_args, ctx) => openTerminal(ctx.sessionID),
},
{
name: "write_terminal",
description:
"Type text into the current session's terminal in the user's TUI, opening one first if needed. Text is sent as-is; include a trailing newline to run a command.",
inputSchema: {
type: "object",
properties: {
text: { type: "string", description: "Text to send to the terminal, e.g. \"ls\\n\" to run ls." },
},
required: ["text"],
},
handler: (args, ctx) => writeTerminal(ctx.sessionID, args.text),
},
],
}),
)
: undefined
}
createEffect(sync)
onCleanup(client.event.on("server.connected", sync))
onCleanup(() => {
state.key = undefined
void state.connection?.close()
})
return null
}
function failure(text: string) {
return { content: [{ type: "text" as const, text }], isError: true }
}
+10 -2
View File
@@ -6,7 +6,7 @@ import { createSimpleContext } from "./helper"
import { useLog } from "./log"
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; url?: string }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; url?: string; headers?: Record<string, string> }>
restart: () => Promise<void>
}
@@ -14,12 +14,13 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent,
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: { api: OpenCodeClient; url?: string; service?: ManagedService }) => {
init: (props: { api: OpenCodeClient; url?: string; headers?: Record<string, string>; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const service = props.service
const events = createGlobalEmitter<ClientEventMap>()
let api = props.api
let url = props.url
let headers = props.headers
let persistentPty = url ? createPersistentPtyClient(api, { url }) : undefined
const connection = createClientConnection(api, {
@@ -28,6 +29,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
const next = await service.reconnect(signal)
api = next.api
if (next.url) url = next.url
headers = next.headers
if (url) persistentPty = createPersistentPtyClient(api, { url })
return api
}
@@ -46,6 +48,12 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
get api() {
return api
},
get url() {
return url
},
get headers() {
return headers
},
get persistentPty() {
if (!persistentPty) throw new Error("Persistent terminal server endpoint is unavailable")
return persistentPty
+29 -5
View File
@@ -18,6 +18,12 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
const event = useEvent()
const [available, setAvailable] = createSignal(false)
const [focus, setFocus] = createSignal<string>()
// Input written before a terminal's pane mounts waits here; the pane drains it when it attaches.
const writers = new Map<string, (data: Uint8Array) => void>()
const pending = new Map<string, Uint8Array[]>()
// Settles once the latest terminal support check finishes; pending until the first connection.
const initial = Promise.withResolvers<void>()
let check = initial.promise
const storage = useStorage()
const [store, update] = storage.store<SessionTerminalsState>("session-terminal-selection", {
initial: { sessions: {} },
@@ -38,9 +44,10 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
})
}
const selectTerminal = async (sessionID: string, ptyID: string | null) => {
// `focus: false` shows the terminal without moving keyboard focus into it, for terminals the user didn't open.
const selectTerminal = async (sessionID: string, ptyID: string | null, options?: { focus?: boolean }) => {
if (ptyID !== null && !terminals[sessionID]?.some((terminal) => terminal.id === ptyID)) return
setFocus(ptyID ?? undefined)
setFocus(options?.focus === false ? undefined : (ptyID ?? undefined))
await update((draft) => {
draft.sessions[sessionID] = ptyID
})
@@ -59,7 +66,7 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
// The event stream opens with server.connected, so this runs for the first connection and every reconnect.
onCleanup(
event.on("server.connected", () => {
void client.api.server
check = client.api.server
.info()
.then((info) => {
// Servers that predate the field omit it; keep offering terminals as clients did before.
@@ -75,11 +82,15 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
setAvailable(false)
console.error("Failed to read server terminal support", error)
})
.finally(initial.resolve)
}),
)
return {
available,
async checked() {
await check
},
get(sessionID: string) {
return {
terminals: terminals[sessionID] ?? [],
@@ -88,7 +99,7 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
},
refresh,
selectTerminal,
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
async newTerminal(sessionID: string, options?: { focus?: boolean }): Promise<PersistentPtyInfo> {
const session = data.session.get(sessionID)
const terminal = await client.api.experimental.persistentPty.create({
sessionID,
@@ -98,9 +109,22 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
env: {},
})
await refresh(sessionID)
await selectTerminal(sessionID, terminal.id)
await selectTerminal(sessionID, terminal.id, options)
return terminal
},
write(ptyID: string, data: Uint8Array) {
const writer = writers.get(ptyID)
if (writer) return writer(data)
pending.set(ptyID, [...(pending.get(ptyID) ?? []), data])
},
attachWriter(ptyID: string, writer: (data: Uint8Array) => void) {
writers.set(ptyID, writer)
pending.get(ptyID)?.forEach(writer)
pending.delete(ptyID)
return () => {
if (writers.get(ptyID) === writer) writers.delete(ptyID)
}
},
shouldFocus(ptyID: string) {
return focus() === ptyID
},
+226
View File
@@ -0,0 +1,226 @@
import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import type { ServerWebSocket } from "bun"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
type ToolResult = { content: { text: string }[]; isError?: boolean }
const session = (id: string) => ({
id,
title: `Fixture ${id}`,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
})
const open = session("ses_mcp_open")
const other = session("ses_mcp_other")
// Served by GET /api/session/:id but missing from the initial list, so the TUI has to sync it on demand.
const lazy = session("ses_mcp_lazy")
const pty = {
id: "pty_fixture",
sessionID: open.id,
title: "Terminal",
command: "/bin/sh",
args: [],
cwd: directory,
status: "running",
pid: 1,
foregroundProcess: null,
size: { cols: 48, rows: 30 },
output: { head: 0, tail: 0 },
}
test.skipIf(process.platform === "win32")("open_terminal opens an unfocused terminal for an open session", async () => {
await using tui = await start(true)
const peer = await tui.mcp.promise
expect(peer.url.searchParams.get("name")).toBe("tui")
expect(peer.url.searchParams.get("location[directory]")).toBe(directory)
const call = await initialize(tui)
const callTool = (sessionID?: string) => call("open_terminal", {}, sessionID)
await tui.setup.waitForFrame((frame) => frame.includes("Client MCP message"))
// Mount and server.connected must not open a second connection.
expect(tui.upgrades).toBe(1)
expect(await callTool()).toMatchObject({ isError: true, content: [{ text: "Unknown session" }] })
expect(await callTool("ses_mcp_missing")).toMatchObject({ isError: true, content: [{ text: "Unknown session" }] })
expect(await callTool(other.id)).toMatchObject({ isError: true, content: [{ text: "session not open in this TUI" }] })
expect(await callTool(lazy.id)).toMatchObject({ isError: true, content: [{ text: "session not open in this TUI" }] })
expect(await callTool(open.id)).toMatchObject({ content: [{ text: "Opened terminal pty_fixture" }] })
expect(tui.created).toEqual([open.id])
await tui.setup.waitForFrame((frame) => frame.includes("Fixture shell"))
await tui.setup.waitForVisualIdle()
expect(tui.setup.renderer.currentFocusedRenderable instanceof EmbeddedTerminalRenderable).toBe(false)
// A second call reuses the session's existing terminal.
expect(await callTool(open.id)).toMatchObject({ content: [{ text: "Opened terminal pty_fixture" }] })
expect(tui.created).toEqual([open.id])
await tui.setup.waitForVisualIdle()
expect(tui.setup.renderer.currentFocusedRenderable instanceof EmbeddedTerminalRenderable).toBe(false)
expect(tui.upgrades).toBe(1)
})
test.skipIf(process.platform === "win32")("write_terminal sends text as-is to the session's terminal", async () => {
await using tui = await start(true)
const call = await initialize(tui)
await tui.setup.waitForFrame((frame) => frame.includes("Client MCP message"))
expect(await call("write_terminal", {}, open.id)).toMatchObject({
isError: true,
content: [{ text: "text must be a string" }],
})
expect(await call("write_terminal", { text: "x" }, other.id)).toMatchObject({
isError: true,
content: [{ text: "session not open in this TUI" }],
})
// With no terminal open, it opens one and delivers the input once the pane attaches.
expect(await call("write_terminal", { text: "echo hi\n" }, open.id)).toMatchObject({
content: [{ text: "Wrote to terminal pty_fixture" }],
})
expect(tui.created).toEqual([open.id])
await tui.setup.waitFor(() => tui.input().includes("echo hi\n"))
expect(await call("write_terminal", { text: "pwd" }, open.id)).toMatchObject({
content: [{ text: "Wrote to terminal pty_fixture" }],
})
await tui.setup.waitFor(() => tui.input() === "echo hi\npwd")
expect(tui.created).toEqual([open.id])
await tui.setup.waitForVisualIdle()
expect(tui.setup.renderer.currentFocusedRenderable instanceof EmbeddedTerminalRenderable).toBe(false)
})
test.skipIf(process.platform === "win32")("does not connect when the experiment is off", async () => {
await using tui = await start(false)
await tui.setup.waitForFrame((frame) => frame.includes("Client MCP message"))
await tui.setup.waitFor(() => tui.requests.includes("/api/info"))
await tui.setup.waitForVisualIdle()
expect(tui.upgrades).toBe(0)
})
async function initialize(tui: Awaited<ReturnType<typeof start>>) {
const peer = await tui.mcp.promise
let next = 0
const request = (method: string, params: unknown) => {
const id = ++next
const response = new Promise<{ result: ToolResult }>((resolve) => tui.responses.set(id, resolve))
peer.socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
return response.then((message) => message.result)
}
await request("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "fixture", version: "1.0.0" },
})
peer.socket.send(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }))
return (name: string, args: Record<string, unknown>, sessionID?: string) =>
request("tools/call", {
name,
arguments: args,
...(sessionID ? { _meta: { "ai.opencode/sessionID": sessionID } } : {}),
})
}
async function start(enabled: boolean) {
const state = await tmpdir()
const setup = await createTestRenderer({ width: 120, height: 36, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const created: string[] = []
const input: Buffer[] = []
const requests: string[] = []
const message = { id: "message-0", type: "user", text: "Client MCP message", time: { created: 0 } }
const calls = createFetch((url, request) => {
requests.push(url.pathname)
if (url.pathname === "/api/session") return json({ data: [open, other], cursor: {} })
for (const item of [open, other, lazy]) {
if (url.pathname === `/api/session/${item.id}`) return json({ data: item })
if (url.pathname === `/api/session/${item.id}/message`) return json({ data: [message], cursor: {} })
if (url.pathname === `/api/session/${item.id}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${item.id}/permission`) return json({ data: [] })
if (url.pathname === `/api/experimental/session/${item.id}/terminal`) {
if (request.method === "POST") created.push(item.id)
return json({ data: request.method === "POST" ? pty : created.includes(item.id) ? [pty] : [] })
}
}
if (url.pathname === "/api/session/ses_mcp_missing") return json({ error: "not found" }, { status: 404 })
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/snapshot")
return json({
data: { info: pty, text: "Fixture shell", checkpoint: Buffer.from("Fixture shell").toString("base64") },
})
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/connect-token")
return json({ data: { ticket: "fixture" } })
return undefined
}, createEventStream())
const mcp = Promise.withResolvers<{ url: URL; socket: ServerWebSocket<{ url: URL }> }>()
const responses = new Map<number, (message: { result: ToolResult }) => void>()
const counter = { upgrades: 0 }
const server = Bun.serve({
port: 0,
idleTimeout: 0,
fetch(request, server) {
const url = new URL(request.url)
const client = url.pathname === "/api/experimental/client-mcp"
if ((client || url.pathname.endsWith("/connect")) && server.upgrade(request, { data: { url } })) {
if (client) counter.upgrades++
return undefined
}
return calls.fetch(request)
},
websocket: {
data: {} as { url: URL },
open(socket) {
if (socket.data.url.pathname === "/api/experimental/client-mcp")
return mcp.resolve({ url: socket.data.url, socket })
socket.send(JSON.stringify({ type: "attached", inputProtocol: 1, role: "controller", info: pty }))
socket.send(JSON.stringify({ type: "replay_complete" }))
},
message(socket, text) {
if (socket.data.url.pathname !== "/api/experimental/client-mcp") {
// Interaction frames: [kind, cols(u16), rows(u16), ...input]; kind 1 carries input.
if (typeof text !== "string" && text[0] === 1) input.push(text.subarray(5))
return
}
const message = JSON.parse(String(text))
responses.get(message.id)?.(message)
},
},
})
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { mode: "off" }, experimental: { "client-mcp": enabled } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: open.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
return {
setup,
created,
requests,
input: () => Buffer.concat(input).toString(),
mcp,
responses,
get upgrades() {
return counter.upgrades
},
async [Symbol.asyncDispose]() {
try {
setup.renderer.destroy()
await task
} finally {
await server.stop(true)
await state[Symbol.asyncDispose]()
}
},
}
}