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
6 changes: 6 additions & 0 deletions .changeset/strict-command-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"opencode-drive": patch
---

Deliver named arrows and modified special keys through terminal escape sequences,
and reject unsupported UI command parameters instead of silently dropping them.
17 changes: 10 additions & 7 deletions packages/drive/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,16 @@ const execute = (
function decodeCommand(command: DriveCommand): Frontend.Request {
if (command.value === undefined && commandInfo[command.operation].value === true)
throw new Error(`${command.operation} requires a value`)
return Frontend.decodeRequest({
jsonrpc: "2.0",
method: command.operation,
...(command.value === undefined
? {}
: { params: JSON.parse(command.value) }),
})
return Frontend.decodeRequest(
{
jsonrpc: "2.0",
method: command.operation,
...(command.value === undefined
? {}
: { params: JSON.parse(command.value) }),
},
{ onExcessProperty: "error" },
)
}

function dispatch(
Expand Down
58 changes: 56 additions & 2 deletions packages/drive/src/simulation/opencode-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ export const make = Effect.fn("OpenCodeRpcProtocol.make")(function* (
)

const wireId = nextWireId++
const payload = message.tag === "ui.press"
? encodePressPayload(message.payload)
: message.payload
pending.set(wireId, {
clientId,
method: message.tag,
Expand All @@ -253,9 +256,9 @@ export const make = Effect.fn("OpenCodeRpcProtocol.make")(function* (
jsonrpc: "2.0",
id: wireId,
method: message.tag,
...(message.payload === undefined || message.payload === null
...(payload === undefined || payload === null
? {}
: { params: message.payload }),
: { params: payload }),
}),
)
},
Expand All @@ -275,6 +278,57 @@ export const make = Effect.fn("OpenCodeRpcProtocol.make")(function* (
)
})

const arrows = {
up: { final: "A", kitty: 57_352 },
down: { final: "B", kitty: 57_353 },
right: { final: "C", kitty: 57_351 },
left: { final: "D", kitty: 57_350 },
} as const

function encodePressPayload(payload: unknown) {
if (typeof payload !== "object" || payload === null) return payload
const key = Reflect.get(payload, "key")
if (typeof key !== "string") return payload
const modifiers = Reflect.get(payload, "modifiers")
const modifier = modifierMask(modifiers)
const named = key.toLowerCase()
const arrowName = named.startsWith("arrow_") ? named.slice(6) : named
const arrow = arrowName === "up"
? arrows.up
: arrowName === "down"
? arrows.down
: arrowName === "right"
? arrows.right
: arrowName === "left"
? arrows.left
: undefined
if (arrow !== undefined) {
if (modifier & 24)
return { key: kittySequence(arrow.kitty, modifier) }
return {
key: modifier === 0
? `\u001b[${arrow.final}`
: `\u001b[1;${modifier + 1}${arrow.final}`,
}
}
if (named === "tab" && modifier !== 0)
return { key: kittySequence(9, modifier) }
return payload
}

function modifierMask(value: unknown) {
if (typeof value !== "object" || value === null) return 0
return (Reflect.get(value, "shift") === true ? 1 : 0) |
(Reflect.get(value, "meta") === true ? 2 : 0) |
(Reflect.get(value, "ctrl") === true ? 4 : 0) |
(Reflect.get(value, "super") === true ? 8 : 0) |
(Reflect.get(value, "hyper") === true ? 16 : 0)
}

function kittySequence(codepoint: number, modifier: number) {
return `\u001b[${codepoint};${modifier + 1}u`
}

function open(endpoint: string) {
return Effect.callback<WebSocket, RpcClientError.RpcClientError>((resume) => {
let socket: WebSocket
Expand Down
11 changes: 11 additions & 0 deletions packages/drive/test/manual/session-switching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export default defineScript({
)
}

if (JSON.stringify(request.body).includes("ALT_DOWN_ROUTE_PROBE"))
return Stream.make(Llm.text("Alt-down route probe complete."))

if (phase === 0) {
phase++
return Stream.make(
Expand Down Expand Up @@ -82,6 +85,14 @@ export default defineScript({
yield* ui.waitFor("Session two complete", { timeout: 20_000 })
yield* ui.screenshot("sessions-second")

yield* ui.press("tab", { ctrl: true })
yield* ui.waitFor("Session one complete")

yield* ui.press("down", { meta: true })
yield* ui.waitFor("Session two complete")
yield* ui.submit("ALT_DOWN_ROUTE_PROBE")
yield* ui.waitFor("Alt-down route probe complete", { timeout: 20_000 })

yield* leader(ui, "l")
yield* ui.waitFor("Sessions")
yield* ui.arrow("down")
Expand Down
44 changes: 44 additions & 0 deletions packages/drive/test/simulation/direct-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,54 @@ test.sequential("CLI drives an externally owned OpenCode endpoint on the default
expect(screenshot.status).toBe(0)
expect(screenshot.stdout.trim()).toBe("/tmp/home.png")

const ctrlTab = await send(root, [
"--command.ui.press",
'{"key":"tab","modifiers":{"ctrl":true}}',
])
expect(ctrlTab.status).toBe(0)

const right = await send(root, [
"--command.ui.press",
'{"key":"right"}',
])
expect(right.status).toBe(0)

const altDown = await send(root, [
"--command.ui.press",
'{"key":"down","modifiers":{"meta":true}}',
])
expect(altDown.status).toBe(0)

const invalidAlt = await send(root, [
"--command.ui.press",
'{"key":"down","modifiers":{"alt":true}}',
])
expect(invalidAlt.status).toBe(1)
expect(invalidAlt.stderr).toContain("alt")
expect(invalidAlt.stderr).toContain("Unexpected key with value true")

expect(requests).toEqual([
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
{ jsonrpc: "2.0", id: 1, method: "ui.screenshot", params: { name: "home" } },
{
jsonrpc: "2.0",
id: 1,
method: "ui.press",
params: { key: "\u001b[9;5u" },
},
{
jsonrpc: "2.0",
id: 1,
method: "ui.press",
params: { key: "\u001b[C" },
},
{
jsonrpc: "2.0",
id: 1,
method: "ui.press",
params: { key: "\u001b[1;3B" },
},
])
} finally {
await server.stop(true)
Expand Down
25 changes: 25 additions & 0 deletions packages/drive/test/simulation/opencode-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ describe("OpenCode Effect RPC compatibility protocol", () => {
expect(yield* client["ui.state"]()).toEqual(state)
expect(yield* client["ui.screenshot"](undefined)).toBe("/tmp/screen.png")
expect(yield* client["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png")
expect(yield* client["ui.press"]({ key: "right" })).toEqual(state)
expect(
yield* client["ui.press"]({ key: "down", modifiers: { meta: true } }),
).toEqual(state)
expect(
yield* client["ui.press"]({ key: "tab", modifiers: { ctrl: true } }),
).toEqual(state)

const error = yield* client["ui.matches"]({ text: "fail" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(SimulationRequestError)
Expand Down Expand Up @@ -72,6 +79,24 @@ describe("OpenCode Effect RPC compatibility protocol", () => {
{
jsonrpc: "2.0",
id: firstId + 3,
method: "ui.press",
params: { key: "\u001b[C" },
},
{
jsonrpc: "2.0",
id: firstId + 4,
method: "ui.press",
params: { key: "\u001b[1;3B" },
},
{
jsonrpc: "2.0",
id: firstId + 5,
method: "ui.press",
params: { key: "\u001b[9;5u" },
},
{
jsonrpc: "2.0",
id: firstId + 6,
method: "ui.matches",
params: { text: "fail" },
},
Expand Down
3 changes: 3 additions & 0 deletions skills/opencode-drive/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ opencode-drive stop --name demo
- `--command.ui.matches '{"text":"OpenCode"}'`
- `--command.ui.recording.finish`

Use `meta` for the terminal Alt modifier. For example:
`--command.ui.press '{"key":"down","modifiers":{"meta":true}}'`.

Start with `--record` to record a headless live instance. `stop` finishes the recording, exports the MP4, performs owner cleanup, and prints the path.

```bash
Expand Down