feat(tui): support fake renderer in simulation mode

- OPENCODE_SIMULATION_RENDERER=fake creates an OpenTUI test renderer
  (in-memory screen buffer) instead of the terminal renderer; size via
  OPENCODE_SIMULATION_TUI_WIDTH/HEIGHT
- Harness carries mockInput/mockMouse and prefers TestRendererSetup
  APIs (captureCharFrame, renderOnce) over the private
  currentRenderBuffer fallback used for the visible renderer
- Fix Bun.Server generic type argument in simulation server
This commit is contained in:
James Long 2026-07-02 18:09:48 +00:00
commit 6fbce7b045
3 changed files with 72 additions and 31 deletions

View file

@ -8,7 +8,8 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { ExitProvider, useExit } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue" import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection" import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
import type { TestRendererSetup } from "@opentui/core/testing"
import { RouteProvider, useRoute } from "./context/route" import { RouteProvider, useRoute } from "./context/route"
import { import {
Switch, Switch,
@ -180,12 +181,24 @@ function isVersionGreater(left: string, right: string) {
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const global = yield* Global.Service const global = yield* Global.Service
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
const simulationEnabled = process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true"
const result = yield* Effect.scoped( const result = yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease( const acquired = yield* Effect.acquireRelease(
Effect.tryPromise({ Effect.tryPromise({
try: () => try: async (): Promise<{ renderer: CliRenderer; setup?: TestRendererSetup }> => {
createCliRenderer({ // The fake renderer is a real CliRenderer backed by an in-memory
// screen buffer; everything downstream (keymap, harness, render)
// treats both renderer kinds identically.
if (simulationEnabled && process.env.OPENCODE_SIMULATION_RENDERER === "fake") {
const { createTestRenderer } = await import("@opentui/core/testing")
const setup = await createTestRenderer({
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
})
return { renderer: setup.renderer, setup }
}
const renderer = await createCliRenderer({
externalOutputMode: "passthrough", externalOutputMode: "passthrough",
targetFps: 60, targetFps: 60,
gatherStats: false, gatherStats: false,
@ -197,14 +210,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
consoleOptions: { consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
}, },
}), })
return { renderer }
},
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}), }),
(renderer) => (acquired) =>
Effect.sync(() => { Effect.sync(() => {
destroyRenderer(renderer) destroyRenderer(acquired.renderer)
}), }),
) )
const renderer = acquired.renderer
win32DisableProcessedInput() win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer) const keymap = createDefaultOpenTuiKeymap(renderer)
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
@ -230,15 +246,16 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
const pluginRuntime = createPluginRuntime() const pluginRuntime = createPluginRuntime()
const simulation = yield* Effect.promise(async () => { if (simulationEnabled) {
if (process.env.OPENCODE_SIMULATION !== "1" && process.env.OPENCODE_SIMULATION !== "true") return const simulation = yield* Effect.promise(async () => {
const { SimulationActions } = await import("./simulation/actions") const { SimulationActions } = await import("./simulation/actions")
const { SimulationServer } = await import("./simulation/server") const { SimulationServer } = await import("./simulation/server")
return SimulationServer.start(SimulationActions.createHarness(renderer)) return SimulationServer.start(SimulationActions.createHarness(renderer, acquired.setup))
}) })
if (simulation) { if (simulation) {
process.stderr.write(`opencode simulation websocket: ${simulation.url}\n`) process.stderr.write(`opencode simulation websocket: ${simulation.url}\n`)
yield* Effect.addFinalizer(() => Effect.sync(() => simulation.stop())) yield* Effect.addFinalizer(() => Effect.sync(() => simulation.stop()))
}
} }
yield* Effect.tryPromise(async () => { yield* Effect.tryPromise(async () => {

View file

@ -1,5 +1,11 @@
import type { CliRenderer, Renderable } from "@opentui/core" import type { CliRenderer, Renderable } from "@opentui/core"
import { createMockKeys, createMockMouse } from "@opentui/core/testing" import {
createMockKeys,
createMockMouse,
type MockInput,
type MockMouse,
type TestRendererSetup,
} from "@opentui/core/testing"
import { SimulationTrace } from "./trace" import { SimulationTrace } from "./trace"
export interface KeyModifiers { export interface KeyModifiers {
@ -33,6 +39,8 @@ export interface Element {
export interface Harness { export interface Harness {
readonly renderer: CliRenderer readonly renderer: CliRenderer
readonly mockInput: MockInput
readonly mockMouse: MockMouse
readonly renderOnce: () => Promise<void> readonly renderOnce: () => Promise<void>
readonly screen: () => string readonly screen: () => string
} }
@ -66,14 +74,28 @@ function hit(renderer: CliRenderer, renderable: Renderable) {
return renderer.hitTest(x, y) === renderable.num return renderer.hitTest(x, y) === renderable.num
} }
export function createHarness(renderer: CliRenderer): Harness { /**
* Builds the harness the simulation server drives.
*
* When the renderer came from `createTestRenderer` (fake renderer), pass its
* `TestRendererSetup` so the harness uses the supported testing APIs. Without
* it (visible terminal renderer) the harness falls back to `requestRender` +
* `idle` and reading the private `currentRenderBuffer`.
*/
export function createHarness(renderer: CliRenderer, setup?: TestRendererSetup): Harness {
return { return {
renderer, renderer,
renderOnce: async () => { mockInput: setup?.mockInput ?? createMockKeys(renderer),
renderer.requestRender() mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
await renderer.idle() renderOnce:
}, setup?.renderOnce ??
screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true)), (async () => {
renderer.requestRender()
await renderer.idle()
}),
screen:
setup?.captureCharFrame ??
(() => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true))),
} }
} }
@ -131,27 +153,25 @@ export function state(harness: Harness) {
} }
export async function execute(harness: Harness, action: Action) { export async function execute(harness: Harness, action: Action) {
const mockInput = createMockKeys(harness.renderer)
const mockMouse = createMockMouse(harness.renderer)
SimulationTrace.add("ui.action", { action }) SimulationTrace.add("ui.action", { action })
switch (action.type) { switch (action.type) {
case "typeText": case "typeText":
await mockInput.typeText(action.text) await harness.mockInput.typeText(action.text)
break break
case "pressKey": case "pressKey":
mockInput.pressKey(action.key, action.modifiers) harness.mockInput.pressKey(action.key, action.modifiers)
break break
case "pressEnter": case "pressEnter":
mockInput.pressEnter() harness.mockInput.pressEnter()
break break
case "pressArrow": case "pressArrow":
mockInput.pressArrow(action.direction) harness.mockInput.pressArrow(action.direction)
break break
case "focus": case "focus":
all(harness.renderer.root).find((item) => item.num === action.target)?.focus() all(harness.renderer.root).find((item) => item.num === action.target)?.focus()
break break
case "click": case "click":
await mockMouse.click(action.x, action.y) await harness.mockMouse.click(action.x, action.y)
break break
} }
await harness.renderOnce() await harness.renderOnce()

View file

@ -118,7 +118,11 @@ async function handle(harness: Harness, request: JsonRpcRequest) {
throw new Error(`Unknown simulation method: ${request.method}`) throw new Error(`Unknown simulation method: ${request.method}`)
} }
function serve(harness: Harness, port = DefaultPort, attempts = MaxPortAttempts): Bun.Server { function serve(
harness: Harness,
port = DefaultPort,
attempts = MaxPortAttempts,
): Bun.Server<{ readonly simulation: true }> {
try { try {
return Bun.serve<{ readonly simulation: true }>({ return Bun.serve<{ readonly simulation: true }>({
hostname: "127.0.0.1", hostname: "127.0.0.1",