From 889c313462ac5879ab62021fef4a95a0c5bb8d26 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 21 Jul 2026 14:34:04 +0000 Subject: [PATCH] feat(tui): expose terminal control queries --- packages/tui/src/app.tsx | 5 + packages/tui/src/terminal-control/index.ts | 36 +++++ .../src/terminal-control/query-provider.d.mts | 15 +++ .../src/terminal-control/query-provider.mjs | 125 ++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 packages/tui/src/terminal-control/index.ts create mode 100644 packages/tui/src/terminal-control/query-provider.d.mts create mode 100644 packages/tui/src/terminal-control/query-provider.mjs diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index f0ec2a59b4..7b319eecb4 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -235,6 +235,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { (renderer) => Effect.sync(() => destroyRenderer(renderer)), ) }) + if (process.env.TERMCTRL_QUERY_SOCKET) { + const { startTerminalControlQueries } = yield* Effect.promise(() => import("./terminal-control")) + const queries = startTerminalControlQueries(renderer) + yield* Effect.addFinalizer(() => Effect.sync(() => queries.close())) + } win32DisableProcessedInput() const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => diff --git a/packages/tui/src/terminal-control/index.ts b/packages/tui/src/terminal-control/index.ts new file mode 100644 index 0000000000..b177b4266b --- /dev/null +++ b/packages/tui/src/terminal-control/index.ts @@ -0,0 +1,36 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Logging } from "@opencode-ai/core/observability/logging" +import { SimulationActions } from "@opencode-ai/simulation/frontend/actions" +import type { CliRenderer } from "@opentui/core" +import { provideTerminalControlQueries } from "./query-provider.mjs" + +export function startTerminalControlQueries(renderer: CliRenderer) { + const harness = SimulationActions.createHarness(renderer) + return provideTerminalControlQueries({ + application: { name: "opencode", version: InstallationVersion }, + queries: { + "ui.snapshot": async () => { + await harness.renderOnce() + const snapshot = SimulationActions.snapshot(harness) + const semanticElements = new Set(snapshot.nodes.map((node) => node.element)) + const semanticIDs = new Set(snapshot.nodes.map((node) => node.id)) + const inferred = SimulationActions.elements(renderer) + .filter((element) => !semanticElements.has(element.num)) + .map((element) => { + const id = element.id && !semanticIDs.has(element.id) ? element.id : `renderable-${element.num}` + semanticIDs.add(id) + return { + id, + role: element.editor ? "textbox" : element.clickable ? "button" : "control", + label: element.id || undefined, + element: element.num, + focused: element.focused || element.editor, + disabled: false, + } + }) + return { ...snapshot, nodes: [...snapshot.nodes, ...inferred] } + }, + logs: () => Logging.file(), + }, + }) +} diff --git a/packages/tui/src/terminal-control/query-provider.d.mts b/packages/tui/src/terminal-control/query-provider.d.mts new file mode 100644 index 0000000000..d48880e657 --- /dev/null +++ b/packages/tui/src/terminal-control/query-provider.d.mts @@ -0,0 +1,15 @@ +export type QueryHandler = ( + params: unknown, + context: { readonly id: number; readonly name: string }, +) => unknown | Promise + +export function provideTerminalControlQueries(options: { + readonly application: { readonly name: string; readonly version?: string } + readonly queries: Readonly> + readonly socketPath?: string | null + readonly onError?: (error: unknown) => void +}): { + readonly enabled: boolean + readonly ready: Promise + close(): void +} diff --git a/packages/tui/src/terminal-control/query-provider.mjs b/packages/tui/src/terminal-control/query-provider.mjs new file mode 100644 index 0000000000..4d49267f4c --- /dev/null +++ b/packages/tui/src/terminal-control/query-provider.mjs @@ -0,0 +1,125 @@ +import { createConnection } from "node:net" + +const socketFromEnvironment = () => process.env.TERMCTRL_QUERY_SOCKET + +export function provideTerminalControlQueries({ + application, + queries, + socketPath = socketFromEnvironment(), + onError = () => {}, +}) { + if (!application?.name) throw new TypeError("application.name is required") + if (!queries || Object.values(queries).some((handler) => typeof handler !== "function")) { + throw new TypeError("queries must be an object of query handlers") + } + if (!socketPath) { + return { enabled: false, ready: Promise.resolve(false), close() {} } + } + + let buffer = "" + let protocolReady = false + let readySettled = false + let resolveReady + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + const settleReady = (value) => { + if (readySettled) return + readySettled = true + resolveReady(value) + } + const socket = createConnection(socketPath) + socket.setEncoding("utf8") + + const handshakeTimer = setTimeout(() => { + fail(new Error("Terminal Control query handshake timed out")) + }, 5_000) + handshakeTimer.unref?.() + + socket.once("connect", () => { + send({ + type: "hello", + protocolVersion: 1, + application, + queries: Object.keys(queries), + }) + }) + + socket.on("data", (chunk) => { + buffer += chunk + let newline + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + try { + receive(JSON.parse(line)) + } catch (error) { + fail(error) + return + } + } + }) + socket.once("error", fail) + socket.once("close", () => { + clearTimeout(handshakeTimer) + settleReady(false) + }) + + function receive(message) { + if (message?.type === "ready" && message.protocolVersion === 1 && !protocolReady) { + protocolReady = true + clearTimeout(handshakeTimer) + settleReady(true) + return + } + if ( + message?.type !== "query" || + !protocolReady || + !Number.isSafeInteger(message.id) || + typeof message.name !== "string" + ) { + throw new Error("Terminal Control sent an invalid query message") + } + + const handler = queries[message.name] + if (!handler) { + sendError(message.id, "QUERY_NOT_SUPPORTED", `Unsupported query ${message.name}`) + return + } + Promise.resolve() + .then(() => handler(message.params, { id: message.id, name: message.name })) + .then((value) => send({ type: "result", id: message.id, value })) + .catch((error) => + sendError( + message.id, + typeof error?.code === "string" ? error.code : "QUERY_FAILED", + error instanceof Error ? error.message : String(error), + ), + ) + } + + function sendError(id, code, message) { + send({ type: "error", id, error: { code, message } }) + } + + function send(message) { + if (!socket.destroyed) socket.write(`${JSON.stringify(message)}\n`) + } + + function fail(error) { + clearTimeout(handshakeTimer) + settleReady(false) + onError(error) + socket.destroy() + } + + return { + enabled: true, + ready, + close() { + clearTimeout(handshakeTimer) + settleReady(false) + socket.destroy() + }, + } +}