This commit is contained in:
James Long 2026-05-14 09:21:34 -04:00
commit f1c0b2651a
4 changed files with 207 additions and 0 deletions

View file

@ -25,3 +25,14 @@
- Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`.
- `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`.
- `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer.
## OpenTUI Action APIs
- Interactable discovery can walk `renderer.root.getChildren()` recursively.
- `Renderable.focusable` and `Renderable.focused` are public and enough to discover focus targets.
- `renderer.currentFocusedEditor` identifies active text input/edit-buffer targets for typing/submission.
- `renderer.hitTest(x, y)` maps terminal coordinates through the hit grid to a renderable id.
- Renderables have public geometry: `screenX`, `screenY`, `width`, `height`, and `num`.
- Mouse listener metadata is stored internally on renderables; first pass checks `_mouseListener` / `_mouseListeners` at runtime to identify clickable targets. This is pragmatic but not a stable public API.
- Test execution uses `mockInput.typeText`, `mockInput.pressEnter`, `mockInput.pressArrow`, and `mockMouse.click` from OpenTUI testing.
- Implemented `SimulationActions` with `elements(...)`, `actions(...)`, and `execute(...)`.

View file

@ -260,6 +260,8 @@ Implementation shape:
- Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer.
- Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`.
- Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner.
- Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`.
- OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution.
- Do not render to a real terminal in simulation mode.
- Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements.
- Investigate OpenTUI APIs for reading the screen buffer from the fake renderer.
@ -271,6 +273,7 @@ Todos:
- [x] Inspect `@opentui/solid` `testRender` capabilities.
- [x] Determine how to get a screen buffer string/snapshot from the fake renderer.
- [x] Determine first structured capture API for interactable discovery: `captureSpans()`.
- [x] Add first pass renderable-based interactable discovery for focused editors, focusable elements, and mouse handlers.
- [x] Add a minimal renderer factory override to `tui(...)` or app startup.
- [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness.
- [ ] Verify TUI starts in fake renderer with no real terminal output.

View file

@ -0,0 +1,129 @@
import type { CliRenderer, Renderable } from "@opentui/core"
export interface MockInput {
readonly typeText: (text: string) => Promise<void>
readonly pressEnter: () => void
readonly pressArrow: (direction: "up" | "down" | "left" | "right") => void
}
export interface MockMouse {
readonly click: (x: number, y: number) => Promise<void>
}
export interface Harness {
readonly renderer: CliRenderer
readonly mockInput: MockInput
readonly mockMouse: MockMouse
readonly renderOnce: () => Promise<void>
}
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 type Action =
| { readonly type: "typeText"; readonly text: string }
| { 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 }
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 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 result: Action[] = []
const items = elements(renderer)
if (renderer.currentFocusedEditor) {
result.push({ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" })
}
result.push(...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })))
result.push(
...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),
})),
)
result.push(
{ type: "pressArrow", direction: "down" },
{ type: "pressArrow", direction: "up" },
)
return result
}
export async function execute(harness: Harness, action: Action) {
switch (action.type) {
case "typeText":
await harness.mockInput.typeText(action.text)
break
case "pressEnter":
harness.mockInput.pressEnter()
break
case "pressArrow":
harness.mockInput.pressArrow(action.direction)
break
case "focus": {
const renderable = all(harness.renderer.root).find((item) => item.num === action.target)
renderable?.focus()
break
}
case "click":
await harness.mockMouse.click(action.x, action.y)
break
}
await harness.renderOnce()
}
export * as SimulationActions from "./actions"

View file

@ -0,0 +1,64 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { SimulationActions } from "../../../src/testing/simulation/actions"
describe("SimulationActions", () => {
test("discovers focused editors and executes text actions", async () => {
const [value, setValue] = createSignal("")
const app = await testRender(
() => <input focused onInput={setValue} width={20} />,
{ width: 40, height: 8 },
)
try {
await app.renderOnce()
const items = SimulationActions.elements(app.renderer)
expect(items.some((item) => item.editor)).toBe(true)
await SimulationActions.execute(app, { type: "typeText", text: "hello" })
expect(value()).toBe("hello")
} finally {
app.renderer.destroy()
}
})
test("discovers focusable elements and executes focus actions", async () => {
let box: any
const app = await testRender(
() => <box ref={box} focusable style={{ width: 10, height: 3 }} />,
{ width: 40, height: 8 },
)
try {
await app.renderOnce()
const target = SimulationActions.elements(app.renderer).find((item) => item.id === box.id)
expect(target?.focusable).toBe(true)
expect(box.focused).toBe(false)
await SimulationActions.execute(app, { type: "focus", target: box.num })
expect(box.focused).toBe(true)
} finally {
app.renderer.destroy()
}
})
test("discovers clickable elements and executes click actions", async () => {
let clicked = 0
const app = await testRender(
() => <box onMouseDown={() => clicked++} style={{ width: 10, height: 3 }} />,
{ width: 40, height: 8, useMouse: true },
)
try {
await app.renderOnce()
const click = SimulationActions.actions(app.renderer).find((action) => action.type === "click")
expect(click).toBeDefined()
await SimulationActions.execute(app, click!)
expect(clicked).toBe(1)
} finally {
app.renderer.destroy()
}
})
})