From 6fbce7b045d2f926ee8a4360335b1aa4355033d1 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 2 Jul 2026 18:09:48 +0000 Subject: [PATCH] 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 --- packages/tui/src/app.tsx | 49 +++++++++++++++++--------- packages/tui/src/simulation/actions.ts | 48 +++++++++++++++++-------- packages/tui/src/simulation/server.ts | 6 +++- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5fe9b571cf..c84ff537b5 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -8,7 +8,8 @@ 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 CliRenderer } from "@opentui/core" +import type { TestRendererSetup } from "@opentui/core/testing" import { RouteProvider, useRoute } from "./context/route" import { Switch, @@ -180,12 +181,24 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const global = yield* Global.Service 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( Effect.gen(function* () { - const renderer = yield* Effect.acquireRelease( + const acquired = yield* Effect.acquireRelease( Effect.tryPromise({ - try: () => - createCliRenderer({ + try: async (): Promise<{ renderer: CliRenderer; setup?: TestRendererSetup }> => { + // 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", targetFps: 60, gatherStats: false, @@ -197,14 +210,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], }, - }), + }) + return { renderer } + }, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), - (renderer) => + (acquired) => Effect.sync(() => { - destroyRenderer(renderer) + destroyRenderer(acquired.renderer) }), ) + const renderer = acquired.renderer win32DisableProcessedInput() const keymap = createDefaultOpenTuiKeymap(renderer) 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)) const pluginRuntime = createPluginRuntime() - const simulation = yield* Effect.promise(async () => { - if (process.env.OPENCODE_SIMULATION !== "1" && process.env.OPENCODE_SIMULATION !== "true") return - const { SimulationActions } = await import("./simulation/actions") - const { SimulationServer } = await import("./simulation/server") - return SimulationServer.start(SimulationActions.createHarness(renderer)) - }) - if (simulation) { - process.stderr.write(`opencode simulation websocket: ${simulation.url}\n`) - yield* Effect.addFinalizer(() => Effect.sync(() => simulation.stop())) + if (simulationEnabled) { + const simulation = yield* Effect.promise(async () => { + const { SimulationActions } = await import("./simulation/actions") + const { SimulationServer } = await import("./simulation/server") + return SimulationServer.start(SimulationActions.createHarness(renderer, acquired.setup)) + }) + if (simulation) { + process.stderr.write(`opencode simulation websocket: ${simulation.url}\n`) + yield* Effect.addFinalizer(() => Effect.sync(() => simulation.stop())) + } } yield* Effect.tryPromise(async () => { diff --git a/packages/tui/src/simulation/actions.ts b/packages/tui/src/simulation/actions.ts index c0ecd82306..0dc341e49a 100644 --- a/packages/tui/src/simulation/actions.ts +++ b/packages/tui/src/simulation/actions.ts @@ -1,5 +1,11 @@ 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" export interface KeyModifiers { @@ -33,6 +39,8 @@ export interface Element { export interface Harness { readonly renderer: CliRenderer + readonly mockInput: MockInput + readonly mockMouse: MockMouse readonly renderOnce: () => Promise readonly screen: () => string } @@ -66,14 +74,28 @@ function hit(renderer: CliRenderer, renderable: Renderable) { 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 { renderer, - renderOnce: async () => { - renderer.requestRender() - await renderer.idle() - }, - screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true)), + 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))), } } @@ -131,27 +153,25 @@ export function state(harness: Harness) { } 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) + await harness.mockInput.typeText(action.text) break case "pressKey": - mockInput.pressKey(action.key, action.modifiers) + harness.mockInput.pressKey(action.key, action.modifiers) break case "pressEnter": - mockInput.pressEnter() + harness.mockInput.pressEnter() break case "pressArrow": - mockInput.pressArrow(action.direction) + harness.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) + await harness.mockMouse.click(action.x, action.y) break } await harness.renderOnce() diff --git a/packages/tui/src/simulation/server.ts b/packages/tui/src/simulation/server.ts index 8df599896d..65d814d2f5 100644 --- a/packages/tui/src/simulation/server.ts +++ b/packages/tui/src/simulation/server.ts @@ -118,7 +118,11 @@ async function handle(harness: Harness, request: JsonRpcRequest) { 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 { return Bun.serve<{ readonly simulation: true }>({ hostname: "127.0.0.1",