From c4e9f3d91a80420522dceb0ab6a543de423b197b Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 1 Jul 2026 10:15:39 -0400 Subject: [PATCH] feat: add simulation control surface --- packages/opencode/specs/simulation-phases.md | 16 +- .../src/cli/cmd/run/runtime.lifecycle.ts | 10 ++ .../src/testing/simulation/actions.ts | 161 +++++++++++++++++ .../opencode/src/testing/simulation/server.ts | 170 ++++++++++++++++++ .../opencode/src/testing/simulation/trace.ts | 37 ++++ 5 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/testing/simulation/actions.ts create mode 100644 packages/opencode/src/testing/simulation/server.ts create mode 100644 packages/opencode/src/testing/simulation/trace.ts diff --git a/packages/opencode/specs/simulation-phases.md b/packages/opencode/specs/simulation-phases.md index 6c00f35e2e..068476f5c5 100644 --- a/packages/opencode/specs/simulation-phases.md +++ b/packages/opencode/specs/simulation-phases.md @@ -12,14 +12,14 @@ This phase proves the core shape without swapping every foundational layer yet. Implementation checklist: -- [ ] Add `OPENCODE_SIMULATION=1` activation in TUI startup. -- [ ] Add simulation trace service with in-memory append-only records. -- [ ] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. -- [ ] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. -- [ ] Add TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`. -- [ ] Expose `ui.state`, `ui.action`, `ui.render`. -- [ ] Expose `trace.list`, `trace.clear`, `trace.export`. -- [ ] Ensure fake and visible renderer paths share the same action protocol. +- [x] Add `OPENCODE_SIMULATION=1` activation in TUI startup. +- [x] Add simulation trace service with in-memory append-only records. +- [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. +- [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. +- [x] Add TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- [x] Expose `ui.state`, `ui.action`, `ui.render`. +- [x] Expose `trace.list`, `trace.clear`, `trace.export`. +- [x] Ensure fake and visible renderer paths share the same action protocol. - [ ] Verify a local driver can inspect state and execute a real TUI input. Scope: diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index 4644d3d036..51d83613ad 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -228,6 +228,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise void } | undefined const footer = new RunFooter(renderer, { directory: input.directory, @@ -278,6 +279,13 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { footer.requestExit() } @@ -338,6 +346,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) } } finally { + simulation?.stop() + simulation = undefined footer.close() await footer.idle().catch(() => {}) footer.destroy() diff --git a/packages/opencode/src/testing/simulation/actions.ts b/packages/opencode/src/testing/simulation/actions.ts new file mode 100644 index 0000000000..c0ecd82306 --- /dev/null +++ b/packages/opencode/src/testing/simulation/actions.ts @@ -0,0 +1,161 @@ +import type { CliRenderer, Renderable } from "@opentui/core" +import { createMockKeys, createMockMouse } from "@opentui/core/testing" +import { SimulationTrace } from "./trace" + +export interface KeyModifiers { + readonly ctrl?: boolean + readonly shift?: boolean + readonly meta?: boolean + readonly super?: boolean + readonly hyper?: boolean +} + +export type Action = + | { readonly type: "typeText"; readonly text: string } + | { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers } + | { readonly type: "pressEnter" } + | { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" } + | { readonly type: "focus"; readonly target: number } + | { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number } + +export interface Element { + readonly id: string + readonly num: number + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly focusable: boolean + readonly focused: boolean + readonly clickable: boolean + readonly editor: boolean +} + +export interface Harness { + readonly renderer: CliRenderer + readonly renderOnce: () => Promise + readonly screen: () => string +} + +type RenderBuffer = { + readonly width: number + readonly height: number + getRealCharBytes(includeAnsi?: boolean): Uint8Array +} + +const decoder = new TextDecoder() + +function children(renderable: Renderable) { + return renderable.getChildren().filter((child): child is Renderable => "num" in child) +} + +function all(renderable: Renderable): Renderable[] { + return [renderable, ...children(renderable).flatMap(all)] +} + +function mouseListeners(renderable: Renderable) { + const general = Reflect.get(renderable, "_mouseListener") + const specific = Reflect.get(renderable, "_mouseListeners") + return Boolean(general) || (specific && typeof specific === "object" && Object.keys(specific).length > 0) +} + +function hit(renderer: CliRenderer, renderable: Renderable) { + if (renderable.width <= 0 || renderable.height <= 0) return false + const x = Math.floor(renderable.screenX + renderable.width / 2) + const y = Math.floor(renderable.screenY + renderable.height / 2) + return renderer.hitTest(x, y) === renderable.num +} + +export function createHarness(renderer: CliRenderer): Harness { + return { + renderer, + renderOnce: async () => { + renderer.requestRender() + await renderer.idle() + }, + screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true)), + } +} + +export function elements(renderer: CliRenderer): Element[] { + return all(renderer.root) + .filter((renderable) => renderable.visible && !renderable.isDestroyed) + .map((renderable) => { + const clickable = mouseListeners(renderable) && hit(renderer, renderable) + return { + id: renderable.id, + num: renderable.num, + x: renderable.screenX, + y: renderable.screenY, + width: renderable.width, + height: renderable.height, + focusable: renderable.focusable, + focused: renderable.focused, + clickable, + editor: renderer.currentFocusedEditor === renderable, + } satisfies Element + }) + .filter((element) => element.focusable || element.clickable || element.editor) +} + +export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] { + const items = elements(renderer) + return [ + ...(renderer.currentFocusedEditor + ? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[]) + : []), + ...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })), + ...items + .filter((item) => item.clickable) + .map((item) => ({ + type: "click" as const, + target: item.num, + x: Math.floor(item.x + item.width / 2), + y: Math.floor(item.y + item.height / 2), + })), + { type: "pressArrow", direction: "down" }, + { type: "pressArrow", direction: "up" }, + ] +} + +export function state(harness: Harness) { + return { + screen: harness.screen(), + focused: { + renderable: harness.renderer.currentFocusedRenderable?.num, + editor: Boolean(harness.renderer.currentFocusedEditor), + }, + elements: elements(harness.renderer), + actions: actions(harness.renderer), + } +} + +export async function execute(harness: Harness, action: Action) { + const mockInput = createMockKeys(harness.renderer) + const mockMouse = createMockMouse(harness.renderer) + SimulationTrace.add("ui.action", { action }) + switch (action.type) { + case "typeText": + await mockInput.typeText(action.text) + break + case "pressKey": + mockInput.pressKey(action.key, action.modifiers) + break + case "pressEnter": + mockInput.pressEnter() + break + case "pressArrow": + mockInput.pressArrow(action.direction) + break + case "focus": + all(harness.renderer.root).find((item) => item.num === action.target)?.focus() + break + case "click": + await mockMouse.click(action.x, action.y) + break + } + await harness.renderOnce() + return state(harness) +} + +export * as SimulationActions from "./actions" diff --git a/packages/opencode/src/testing/simulation/server.ts b/packages/opencode/src/testing/simulation/server.ts new file mode 100644 index 0000000000..8df599896d --- /dev/null +++ b/packages/opencode/src/testing/simulation/server.ts @@ -0,0 +1,170 @@ +import { SimulationActions, type Action, type Harness } from "./actions" +import { SimulationTrace } from "./trace" + +const DefaultPort = 40900 +const MaxPortAttempts = 100 + +type JsonRpcRequest = { + readonly jsonrpc: "2.0" + readonly id?: string | number | null + readonly method: string + readonly params?: unknown +} + +type JsonRpcResponse = { + readonly jsonrpc: "2.0" + readonly id: string | number | null + readonly result?: unknown + readonly error?: { + readonly code: number + readonly message: string + readonly data?: unknown + } +} + +export interface Server { + readonly url: string + readonly stop: () => void +} + +function isEnabled() { + return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true" +} + +function isPortUnavailable(error: unknown) { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use") +} + +function parseRequest(input: string | Buffer): JsonRpcRequest { + const value = JSON.parse(typeof input === "string" ? input : input.toString()) as unknown + if (typeof value !== "object" || value === null) throw new Error("Invalid JSON-RPC request") + if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC version") + if (!("method" in value) || typeof value.method !== "string") throw new Error("Invalid JSON-RPC method") + return value as JsonRpcRequest +} + +function isAction(input: unknown): input is Action { + if (typeof input !== "object" || input === null || !("type" in input)) return false + switch (input.type) { + case "typeText": + return "text" in input && typeof input.text === "string" + case "pressKey": + return "key" in input && typeof input.key === "string" + case "pressEnter": + return true + case "pressArrow": + return "direction" in input && ["up", "down", "left", "right"].includes(String(input.direction)) + case "focus": + return "target" in input && typeof input.target === "number" + case "click": + return ( + "target" in input && + typeof input.target === "number" && + "x" in input && + typeof input.x === "number" && + "y" in input && + typeof input.y === "number" + ) + } + return false +} + +function actionParam(params: unknown) { + if (typeof params !== "object" || params === null || !("action" in params)) throw new Error("Missing action") + if (!isAction(params.action)) throw new Error("Invalid action") + return params.action +} + +function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined { + if (id === undefined) return undefined + return { jsonrpc: "2.0", id, result } +} + +function errorResponse(id: JsonRpcRequest["id"], error: unknown): JsonRpcResponse { + return { + jsonrpc: "2.0", + id: id ?? null, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + }, + } +} + +async function handle(harness: Harness, request: JsonRpcRequest) { + switch (request.method) { + case "ui.state": { + const result = SimulationActions.state(harness) + SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length }) + return result + } + case "ui.action": + return SimulationActions.execute(harness, actionParam(request.params)) + case "ui.render": { + await harness.renderOnce() + const result = SimulationActions.state(harness) + SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length }) + return result + } + case "trace.list": + return { records: SimulationTrace.list() } + case "trace.clear": + SimulationTrace.clear() + return { cleared: true } + case "trace.export": + return SimulationTrace.exportTrace() + } + throw new Error(`Unknown simulation method: ${request.method}`) +} + +function serve(harness: Harness, port = DefaultPort, attempts = MaxPortAttempts): Bun.Server { + try { + return Bun.serve<{ readonly simulation: true }>({ + hostname: "127.0.0.1", + port, + fetch(request, server) { + if (server.upgrade(request, { data: { simulation: true } })) return undefined + return new Response("opencode simulation websocket", { status: 426 }) + }, + websocket: { + open() { + SimulationTrace.add("control.connect") + }, + close() { + SimulationTrace.add("control.disconnect") + }, + async message(socket, message) { + let request: JsonRpcRequest | undefined + try { + request = parseRequest(message) + const result = await handle(harness, request) + const next = response(request.id, result) + if (next) socket.send(JSON.stringify(next)) + } catch (error) { + socket.send(JSON.stringify(errorResponse(request?.id, error))) + } + }, + }, + }) + } catch (error) { + if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error + return serve(harness, port + 1, attempts - 1) + } +} + +export function start(harness: Harness): Server | undefined { + if (!isEnabled()) return + const server = serve(harness) + const url = `ws://${server.hostname}:${server.port}` + SimulationTrace.add("control.start", { url }) + return { + url, + stop: () => { + SimulationTrace.add("control.stop", { url }) + server.stop(true) + }, + } +} + +export * as SimulationServer from "./server" diff --git a/packages/opencode/src/testing/simulation/trace.ts b/packages/opencode/src/testing/simulation/trace.ts new file mode 100644 index 0000000000..b0914faace --- /dev/null +++ b/packages/opencode/src/testing/simulation/trace.ts @@ -0,0 +1,37 @@ +export type TraceRecord = { + readonly id: number + readonly time: string + readonly type: string + readonly data?: unknown +} + +const records: TraceRecord[] = [] +let nextID = 0 + +export function add(type: string, data?: unknown) { + const record = { + id: ++nextID, + time: new Date().toISOString(), + type, + ...(data === undefined ? {} : { data }), + } satisfies TraceRecord + records.push(record) + return record +} + +export function list() { + return [...records] +} + +export function clear() { + records.length = 0 + nextID = 0 +} + +export function exportTrace() { + return { + records: list(), + } +} + +export * as SimulationTrace from "./trace"