Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/semantic-ui-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": minor
---

Expose semantic UI snapshots, exact semantic node polling, and safe semantic-node clicks for compatible OpenCode endpoints.
33 changes: 28 additions & 5 deletions packages/drive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,15 +453,38 @@ Finish a tool-calling response with `Llm.finish("tool-calls")`. Streamed calls
drive OpenCode's normal tool-input start, delta, and end lifecycle; `Llm.raw()`
remains available for provider-wire scenarios not covered by these helpers.

Current OpenCode simulation endpoints expose a semantic UI tree alongside
renderer state and terminal capture. Use `ui.snapshot()` for the complete
versioned tree or `ui.getNode()` to poll for one exact semantic match. Semantic
nodes carry stable IDs, optional occurrence identity, role, label, hierarchy,
component-owned state, and a transient element handle that `ui.click()` can
resolve safely:

```ts
const allow = yield* ui.getNode({
role: "option",
label: "Allow once",
selected: true,
disabled: false,
})

yield* ui.click(allow)
```

`ui.snapshot` and atomic semantic clicks are negotiated as optional
capabilities so ordinary operations remain compatible with older OpenCode
checkouts. Calling `ui.snapshot()`, `ui.getNode()`, or `ui.click(node)` when its
required capability is unavailable fails locally with `UiCapabilityError`.

Capability errors are typed and the concrete classes are grouped under
`Errors`. UI timeouts remain owner-fatal even when caught; recover locally
from errors for which the script has a truthful fallback:

Polling timeouts from `ui.waitFor` and `ui.getElement` make one best-effort,
bounded `ui.capture` request. When it succeeds, the resulting normalized
terminal frame is available as `error.frame` without creating or retaining a
screenshot file. RPC-level timeouts and failed diagnostic captures leave
`error.frame` undefined.
Polling timeouts from `ui.waitFor`, `ui.getElement`, and `ui.getNode` make one
best-effort, bounded `ui.capture` request. When it succeeds, the resulting
normalized terminal frame is available as `error.frame` without creating or
retaining a screenshot file. RPC-level timeouts and failed diagnostic captures
leave `error.frame` undefined.

```ts
import { Effect } from "effect"
Expand Down
16 changes: 16 additions & 0 deletions packages/drive/docs/open-code-driver-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,22 @@ Predicates passed to `ui.waitFor` may return a boolean or an Effect.
Capability methods expose typed error channels. Concrete tagged errors are
available from the `Errors` namespace.

`ui.snapshot()` returns the endpoint's versioned semantic tree. `ui.getNode()`
polls for one exact match and fails with `UiNodeAmbiguousError` when more than
one node matches. Semantic snapshots and identity-checked semantic clicks are
optional during negotiation, so older OpenCode checkouts retain ordinary UI
control while unsupported semantic operations fail locally with
`UiCapabilityError`.

```ts
const option = yield* ui.getNode({
role: "option",
label: "Allow once",
selected: true,
})
yield* ui.click(option)
```

### Additional TUI

```ts
Expand Down
35 changes: 32 additions & 3 deletions packages/drive/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const commandInfo = {
value: false,
description: "Return focus, elements, and available UI actions",
},
"ui.snapshot": {
value: false,
description: "Return the semantic UI tree as JSON",
},
"ui.matches": {
value: true,
description: "Check for literal screen text using JSON params",
Expand All @@ -44,15 +48,17 @@ export const commandInfo = {
description: "Finish recording and return the timeline path",
},
} as const satisfies Record<
Frontend.Capability,
Exclude<Frontend.Capability, "ui.click.semantic">,
{ readonly value: boolean | "optional"; readonly description: string }
>

export function isCommandName(operation: string): operation is Frontend.Capability {
type CommandName = Exclude<Frontend.Capability, "ui.click.semantic">

export function isCommandName(operation: string): operation is CommandName {
return Object.hasOwn(commandInfo, operation)
}

export function commandAcceptsValue(operation: Frontend.Capability) {
export function commandAcceptsValue(operation: CommandName) {
return commandInfo[operation].value
}

Expand Down Expand Up @@ -179,6 +185,27 @@ function dispatch(
connection: SimulationConnector.UiConnection,
request: Frontend.Request,
): Effect.Effect<unknown, unknown> {
if (
request.method === "ui.snapshot" &&
!SimulationConnector.supportsCapability(connection.compatibility, "ui.snapshot")
)
return Effect.fail(
new SimulationError(
"ui.snapshot is not available on this OpenCode endpoint",
request.method,
),
)
if (
request.method === "ui.click" &&
request.params.semantic !== undefined &&
!SimulationConnector.supportsCapability(connection.compatibility, "ui.click.semantic")
)
return Effect.fail(
new SimulationError(
"semantic ui.click is not available on this OpenCode endpoint",
request.method,
),
)
switch (request.method) {
case "ui.type":
return connection.rpc["ui.type"](request.params)
Expand All @@ -200,6 +227,8 @@ function dispatch(
return connection.rpc["ui.capture"]()
case "ui.state":
return connection.rpc["ui.state"]()
case "ui.snapshot":
return connection.rpc["ui.snapshot"]()
case "ui.matches":
return connection.rpc["ui.matches"](request.params)
case "ui.recording.finish":
Expand Down
2 changes: 1 addition & 1 deletion packages/drive/src/cli/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export async function send(options: SendOptions) {
}
if (
options.commands.length === 1 &&
["ui.state", "ui.capture"].includes(
["ui.state", "ui.snapshot", "ui.capture"].includes(
options.commands[0]?.operation ?? "",
)
) {
Expand Down
2 changes: 1 addition & 1 deletion packages/drive/src/cli/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Frontend } from "../client/index.js"

export interface DriveCommand {
readonly operation: Frontend.Capability
readonly operation: Exclude<Frontend.Capability, "ui.click.semantic">
readonly value?: string
}

Expand Down
2 changes: 2 additions & 0 deletions packages/drive/src/driver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ export {
LlmSettlementError,
} from "./llm-controller.js"
export {
UiCapabilityError,
UiElementAmbiguousError,
UiNodeAmbiguousError,
UiPredicateError,
UiTimeoutError,
UiWaitOptionsError,
Expand Down
8 changes: 5 additions & 3 deletions packages/drive/src/driver/llm-responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import * as Schema from "effect/Schema"
import * as Stream from "effect/Stream"
import * as Llm from "../llm/index.js"
import { chunkText } from "../llm/internal.js"
import type { BackendConnection } from "../simulation/connector.js"
import {
supportsCapability,
type BackendConnection,
} from "../simulation/connector.js"
import { controllerError, LlmControllerError } from "./llm-errors.js"

/**
Expand Down Expand Up @@ -70,8 +73,7 @@ export const make = ({ requestTimeout }: Options): Responder => {
): Effect.Effect<never, E | InvocationTerminated> =>
Effect.gen(function* () {
if (
backend.compatibility._tag !== "Negotiated" ||
!backend.compatibility.capabilities.includes("llm.pending")
!supportsCapability(backend.compatibility, "llm.pending")
)
return yield* Effect.fail(error)
const pending = yield* Effect.exit(
Expand Down
Loading