Compare commits

...

3 Commits

Author SHA1 Message Date
James Long 7690ae9897 refactor(tui): specialize semantic snapshot provider 2026-07-21 19:59:19 +00:00
James Long 4e38121a60 fix(tui): rename log files query 2026-07-21 18:31:59 +00:00
James Long 889c313462 feat(tui): expose terminal control queries 2026-07-21 14:34:04 +00:00
4 changed files with 166 additions and 0 deletions
+5
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_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>>()
yield* Effect.addFinalizer(() =>
@@ -0,0 +1,32 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { SimulationActions } from "@opencode-ai/simulation/frontend/actions"
import type { CliRenderer } from "@opentui/core"
import { provideTerminalControlSemanticSnapshot } from "./semantic-provider.mjs"
export function startTerminalControlSemantics(renderer: CliRenderer) {
const harness = SimulationActions.createHarness(renderer)
return provideTerminalControlSemanticSnapshot({
application: { name: "opencode", version: InstallationVersion },
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] }
},
})
}
@@ -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
@@ -0,0 +1,117 @@
import { createConnection } from "node:net"
const socketFromEnvironment = () => process.env.TERMCTRL_SEMANTIC_SOCKET
export function provideTerminalControlSemanticSnapshot({
application,
snapshot,
socketPath = socketFromEnvironment(),
onError = () => {},
}) {
if (!application?.name) throw new TypeError("application.name is required")
if (typeof snapshot !== "function") throw new TypeError("snapshot must be a function")
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 semantic handshake timed out"))
}, 5_000)
handshakeTimer.unref?.()
socket.once("connect", () => {
send({
type: "hello",
protocolVersion: 1,
application,
capabilities: ["semantic.snapshot"],
})
})
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 !== "semantic.snapshot" ||
!protocolReady ||
!Number.isSafeInteger(message.id)
) {
throw new Error("Terminal Control sent an invalid semantic snapshot request")
}
Promise.resolve()
.then(snapshot)
.then((value) => send({ type: "result", id: message.id, value }))
.catch((error) =>
sendError(
message.id,
typeof error?.code === "string" ? error.code : "SNAPSHOT_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()
},
}
}