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:
parent
1d85015e17
commit
6fbce7b045
3 changed files with 72 additions and 31 deletions
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue