feat: simulation control surface and architecture spec (#34801)
This commit is contained in:
parent
c9604c86ec
commit
0405518180
8 changed files with 1112 additions and 4 deletions
|
|
@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
|||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { EpilogueProvider } from "./context/epilogue"
|
||||
import * as Selection from "./util/selection"
|
||||
import { createCliRenderer, MouseButton } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "./context/route"
|
||||
import {
|
||||
Switch,
|
||||
|
|
@ -184,8 +184,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
Effect.gen(function* () {
|
||||
const renderer = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
createCliRenderer({
|
||||
try: async () => {
|
||||
const options = {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
|
|
@ -197,7 +197,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
}),
|
||||
} satisfies CliRendererConfig
|
||||
|
||||
if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") {
|
||||
const { Simulation } = await import("./simulation/simulation")
|
||||
return Simulation.createSimulation(options)
|
||||
}
|
||||
|
||||
return createCliRenderer(options)
|
||||
},
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
(renderer) =>
|
||||
|
|
|
|||
177
packages/tui/src/simulation/actions.ts
Normal file
177
packages/tui/src/simulation/actions.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
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 mockInput: MockInput
|
||||
readonly mockMouse: MockMouse
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the harness the simulation server drives.
|
||||
*
|
||||
* When the renderer is the fake simulation renderer, its TestRendererSetup
|
||||
* provides the supported testing APIs. For the visible terminal renderer the
|
||||
* harness falls back to `requestRender` + `idle` and reading the private
|
||||
* `currentRenderBuffer`.
|
||||
*/
|
||||
export function createHarness(renderer: CliRenderer): Harness {
|
||||
const setup = SimulationRenderer.setupFor(renderer)
|
||||
return {
|
||||
renderer,
|
||||
mockInput: setup?.mockInput ?? createMockKeys(renderer),
|
||||
mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
|
||||
renderOnce:
|
||||
setup?.renderOnce ??
|
||||
(async () => {
|
||||
renderer.requestRender()
|
||||
await renderer.idle()
|
||||
}),
|
||||
screen:
|
||||
setup?.captureCharFrame ??
|
||||
(() => 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) {
|
||||
SimulationTrace.add("ui.action", { action })
|
||||
switch (action.type) {
|
||||
case "typeText":
|
||||
await harness.mockInput.typeText(action.text)
|
||||
break
|
||||
case "pressKey":
|
||||
harness.mockInput.pressKey(action.key, action.modifiers)
|
||||
break
|
||||
case "pressEnter":
|
||||
harness.mockInput.pressEnter()
|
||||
break
|
||||
case "pressArrow":
|
||||
harness.mockInput.pressArrow(action.direction)
|
||||
break
|
||||
case "focus":
|
||||
all(harness.renderer.root).find((item) => item.num === action.target)?.focus()
|
||||
break
|
||||
case "click":
|
||||
await harness.mockMouse.click(action.x, action.y)
|
||||
break
|
||||
}
|
||||
await harness.renderOnce()
|
||||
return state(harness)
|
||||
}
|
||||
|
||||
export * as SimulationActions from "./actions"
|
||||
26
packages/tui/src/simulation/renderer.ts
Normal file
26
packages/tui/src/simulation/renderer.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { CliRenderer, CliRendererConfig } from "@opentui/core"
|
||||
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||
|
||||
const setups = new WeakMap<CliRenderer, TestRendererSetup>()
|
||||
|
||||
/**
|
||||
* Creates the fake simulation renderer: a real CliRenderer backed by an
|
||||
* in-memory screen buffer instead of a terminal. The TestRendererSetup is
|
||||
* kept module-side (keyed by renderer) so the harness can use the supported
|
||||
* testing APIs without app code carrying it around.
|
||||
*/
|
||||
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const setup = await createTestRenderer({
|
||||
...options,
|
||||
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
|
||||
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
|
||||
})
|
||||
setups.set(setup.renderer, setup)
|
||||
return setup.renderer
|
||||
}
|
||||
|
||||
export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
|
||||
return setups.get(renderer)
|
||||
}
|
||||
|
||||
export * as SimulationRenderer from "./renderer"
|
||||
174
packages/tui/src/simulation/server.ts
Normal file
174
packages/tui/src/simulation/server.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
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<{ readonly simulation: true }> {
|
||||
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"
|
||||
27
packages/tui/src/simulation/simulation.ts
Normal file
27
packages/tui/src/simulation/simulation.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { SimulationActions } from "./actions"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationServer } from "./server"
|
||||
|
||||
/**
|
||||
* Simulation-mode renderer entry point.
|
||||
*
|
||||
* Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the
|
||||
* normal visible renderer otherwise) and starts the simulation control
|
||||
* server against it. The server stops when the renderer is destroyed, so the
|
||||
* caller only manages the renderer lifecycle.
|
||||
*/
|
||||
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const renderer =
|
||||
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
|
||||
? await SimulationRenderer.create(options)
|
||||
: await createCliRenderer(options)
|
||||
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
|
||||
if (server) {
|
||||
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
|
||||
renderer.once("destroy", () => server.stop())
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
export * as Simulation from "./simulation"
|
||||
37
packages/tui/src/simulation/trace.ts
Normal file
37
packages/tui/src/simulation/trace.ts
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue