mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(tui): expose open_terminal over client MCP (experimental) (#57506)
This commit is contained in:
committed by
GitHub
Unverified
parent
4a6dacec5e
commit
0b93f0b749
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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]()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user