feat(tui): expose terminal control queries

This commit is contained in:
James Long 2026-07-21 14:34:04 +00:00
commit 889c313462
4 changed files with 181 additions and 0 deletions

View file

@ -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<void>>()
yield* Effect.addFinalizer(() =>

View file

@ -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(),
},
})
}

View file

@ -0,0 +1,15 @@
export type QueryHandler = (
params: unknown,
context: { readonly id: number; readonly name: string },
) => unknown | Promise<unknown>
export function provideTerminalControlQueries(options: {
readonly application: { readonly name: string; readonly version?: string }
readonly queries: Readonly<Record<string, QueryHandler>>
readonly socketPath?: string | null
readonly onError?: (error: unknown) => void
}): {
readonly enabled: boolean
readonly ready: Promise<boolean>
close(): void
}

View file

@ -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()
},
}
}