feat: add simulation control surface

This commit is contained in:
James Long
2026-07-01 10:15:39 -04:00
parent d0a75c5df3
commit c4e9f3d91a
5 changed files with 386 additions and 8 deletions
+8 -8
View File
@@ -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:
@@ -228,6 +228,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
const { RunFooter } = await footerTask
let closed = false
let sigintRegistered = false
let simulation: { readonly url: string; readonly stop: () => void } | undefined
const footer = new RunFooter(renderer, {
directory: input.directory,
@@ -278,6 +279,13 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onSubagentSelect: input.onSubagentSelect,
})
if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") {
const { SimulationActions } = await import("@/testing/simulation/actions")
const { SimulationServer } = await import("@/testing/simulation/server")
simulation = SimulationServer.start(SimulationActions.createHarness(renderer))
if (simulation) process.stderr.write(`opencode simulation websocket: ${simulation.url}\n`)
}
const sigint = () => {
footer.requestExit()
}
@@ -338,6 +346,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
await renderer.idle().catch(() => {})
}
} finally {
simulation?.stop()
simulation = undefined
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
@@ -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<void>
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"
@@ -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"
@@ -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"