From 0b93f0b7499fde0ff29ba4bb28a964902df82cc7 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 22 Oct 2026 16:16:28 -0400 Subject: [PATCH] feat(tui): expose open_terminal over client MCP (experimental) (#57506) --- packages/tui/src/app.tsx | 14 +- .../tui/src/component/dialog-experiments.tsx | 5 + packages/tui/src/component/terminal-pane.tsx | 4 + packages/tui/src/config/index.tsx | 6 + packages/tui/src/context/client-mcp.tsx | 119 +++++++++ packages/tui/src/context/client.tsx | 12 +- .../tui/src/context/session-terminals.tsx | 34 ++- packages/tui/test/client-mcp.test.tsx | 226 ++++++++++++++++++ 8 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 packages/tui/src/context/client-mcp.tsx create mode 100644 packages/tui/test/client-mcp.test.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index e3ae50ab63b..729c07a5376 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 } > - + @@ -408,6 +415,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { source={createThemeSource(global.config)} > + diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index a8830000f88..4000fa0c35e 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -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() { diff --git a/packages/tui/src/component/terminal-pane.tsx b/packages/tui/src/component/terminal-pane.tsx index 23283789ff4..596926901c5 100644 --- a/packages/tui/src/component/terminal-pane.tsx +++ b/packages/tui/src/component/terminal-pane.tsx @@ -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() 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) { diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 87c9a1bb373..ec23025336d 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -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 +} diff --git a/packages/tui/src/context/client-mcp.tsx b/packages/tui/src/context/client-mcp.tsx new file mode 100644 index 00000000000..01bd0d24713 --- /dev/null +++ b/packages/tui/src/context/client-mcp.tsx @@ -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_`. +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 } +} diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index cedd8b92118..6f30f963f09 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -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 }> restart: () => Promise } @@ -14,12 +14,13 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract { + init: (props: { api: OpenCodeClient; url?: string; headers?: Record; service?: ManagedService }) => { const log = useLog({ component: "client" }) const service = props.service const events = createGlobalEmitter() 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 diff --git a/packages/tui/src/context/session-terminals.tsx b/packages/tui/src/context/session-terminals.tsx index c6fcc2d4005..0e01dededd9 100644 --- a/packages/tui/src/context/session-terminals.tsx +++ b/packages/tui/src/context/session-terminals.tsx @@ -18,6 +18,12 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = const event = useEvent() const [available, setAvailable] = createSignal(false) const [focus, setFocus] = createSignal() + // Input written before a terminal's pane mounts waits here; the pane drains it when it attaches. + const writers = new Map void>() + const pending = new Map() + // Settles once the latest terminal support check finishes; pending until the first connection. + const initial = Promise.withResolvers() + let check = initial.promise const storage = useStorage() const [store, update] = storage.store("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 { + async newTerminal(sessionID: string, options?: { focus?: boolean }): Promise { 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 }, diff --git a/packages/tui/test/client-mcp.test.tsx b/packages/tui/test/client-mcp.test.tsx new file mode 100644 index 00000000000..abacafe6302 --- /dev/null +++ b/packages/tui/test/client-mcp.test.tsx @@ -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>) { + 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, 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 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]() + } + }, + } +}