feat(tui): expose terminal control queries
This commit is contained in:
parent
a2c4c7a64b
commit
889c313462
4 changed files with 181 additions and 0 deletions
|
|
@ -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(() =>
|
||||
|
|
|
|||
36
packages/tui/src/terminal-control/index.ts
Normal file
36
packages/tui/src/terminal-control/index.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
15
packages/tui/src/terminal-control/query-provider.d.mts
Normal file
15
packages/tui/src/terminal-control/query-provider.d.mts
Normal 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
|
||||
}
|
||||
125
packages/tui/src/terminal-control/query-provider.mjs
Normal file
125
packages/tui/src/terminal-control/query-provider.mjs
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue