refactor(tui): specialize semantic snapshot provider

This commit is contained in:
James Long 2026-07-21 19:59:19 +00:00
commit 7690ae9897
5 changed files with 50 additions and 65 deletions

View file

@ -235,10 +235,10 @@ 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()))
if (process.env.TERMCTRL_SEMANTIC_SOCKET) {
const { startTerminalControlSemantics } = yield* Effect.promise(() => import("./terminal-control"))
const semantics = startTerminalControlSemantics(renderer)
yield* Effect.addFinalizer(() => Effect.sync(() => semantics.close()))
}
win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>()

View file

@ -1,36 +1,32 @@
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"
import { provideTerminalControlSemanticSnapshot } from "./semantic-provider.mjs"
export function startTerminalControlQueries(renderer: CliRenderer) {
export function startTerminalControlSemantics(renderer: CliRenderer) {
const harness = SimulationActions.createHarness(renderer)
return provideTerminalControlQueries({
return provideTerminalControlSemanticSnapshot({
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] }
},
"log-files": () => Logging.file(),
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 { format: "termctrl-semantic-snapshot-v1", nodes: [...snapshot.nodes, ...inferred] }
},
})
}

View file

@ -1,15 +0,0 @@
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,12 @@
export interface SemanticProvider {
readonly enabled: boolean
readonly ready: Promise<boolean>
close(): void
}
export function provideTerminalControlSemanticSnapshot(options: {
readonly application: { readonly name: string; readonly version?: string }
readonly snapshot: () => unknown | Promise<unknown>
readonly socketPath?: string | null
readonly onError?: (error: unknown) => void
}): SemanticProvider

View file

@ -1,17 +1,15 @@
import { createConnection } from "node:net"
const socketFromEnvironment = () => process.env.TERMCTRL_QUERY_SOCKET
const socketFromEnvironment = () => process.env.TERMCTRL_SEMANTIC_SOCKET
export function provideTerminalControlQueries({
export function provideTerminalControlSemanticSnapshot({
application,
queries,
snapshot,
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 (typeof snapshot !== "function") throw new TypeError("snapshot must be a function")
if (!socketPath) {
return { enabled: false, ready: Promise.resolve(false), close() {} }
}
@ -32,7 +30,7 @@ export function provideTerminalControlQueries({
socket.setEncoding("utf8")
const handshakeTimer = setTimeout(() => {
fail(new Error("Terminal Control query handshake timed out"))
fail(new Error("Terminal Control semantic handshake timed out"))
}, 5_000)
handshakeTimer.unref?.()
@ -41,7 +39,7 @@ export function provideTerminalControlQueries({
type: "hello",
protocolVersion: 1,
application,
queries: Object.keys(queries),
capabilities: ["semantic.snapshot"],
})
})
@ -73,26 +71,20 @@ export function provideTerminalControlQueries({
return
}
if (
message?.type !== "query" ||
message?.type !== "semantic.snapshot" ||
!protocolReady ||
!Number.isSafeInteger(message.id) ||
typeof message.name !== "string"
!Number.isSafeInteger(message.id)
) {
throw new Error("Terminal Control sent an invalid query message")
throw new Error("Terminal Control sent an invalid semantic snapshot request")
}
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(snapshot)
.then((value) => send({ type: "result", id: message.id, value }))
.catch((error) =>
sendError(
message.id,
typeof error?.code === "string" ? error.code : "QUERY_FAILED",
typeof error?.code === "string" ? error.code : "SNAPSHOT_FAILED",
error instanceof Error ? error.message : String(error),
),
)