mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
feat(simulation): slim production bridge (#42584)
This commit is contained in:
@@ -774,11 +774,6 @@
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -786,10 +781,12 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -6460,12 +6457,6 @@
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/simulation/@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
|
||||
|
||||
"@opencode-ai/simulation/@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
|
||||
|
||||
"@opencode-ai/simulation/@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
|
||||
|
||||
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
|
||||
|
||||
"@opencode-ai/storybook/react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { brotliCompressSync, constants } from "node:zlib"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||
if (options?.skipBuild) return compress({})
|
||||
@@ -9,8 +9,10 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await files(path.join(root, "dist")))
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
.map((key) => key.replaceAll(path.sep, "/"))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.toSorted()
|
||||
.map(async (key) => {
|
||||
const source = path.join(root, "dist", key)
|
||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||
@@ -31,16 +33,3 @@ function compress(assets: object) {
|
||||
function isText(key: string) {
|
||||
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.toSorted()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact } from "./verify-artifact"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -91,6 +92,7 @@ for (const target of targets) {
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
await assertTextImportsInlined("dist-node/opencode.mjs")
|
||||
if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs")
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
@@ -139,6 +141,7 @@ for (const target of targets) {
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
await verifyArtifact(path.join(outdir, name))
|
||||
if (host) await smoke(output)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -76,6 +77,16 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const simulationInputs = new Set<string>()
|
||||
const simulationGraphPlugin: BunPlugin = {
|
||||
name: "opencode-simulation-graph",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /packages[/\\]simulation[/\\]src[/\\](frontend[/\\](simulation|server)|control-server)\.ts$/ },
|
||||
(args) => void simulationInputs.add(args.path),
|
||||
)
|
||||
},
|
||||
}
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
const parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
@@ -92,7 +103,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@@ -123,6 +134,7 @@ for (const item of targets) {
|
||||
for (const log of result.logs) console.error(log)
|
||||
process.exit(1)
|
||||
}
|
||||
verifySimulationGraph(simulationInputs)
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, name, "package.json"),
|
||||
@@ -139,6 +151,7 @@ for (const item of targets) {
|
||||
2,
|
||||
),
|
||||
)
|
||||
await verifyArtifact(path.join(outdir, name))
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export async function collectFiles(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map(async (entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? collectFiles(root, target) : [path.relative(root, target)]
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import { copyFile, mkdir, readFile, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -16,17 +17,6 @@ export type NodeAsset = {
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
@@ -51,7 +41,7 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(await files(ptyRoot))
|
||||
...(await collectFiles(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
key: `${target.nodePtyPackage}/${relative}`,
|
||||
@@ -85,5 +75,7 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) {
|
||||
|
||||
export async function seaAssetMap() {
|
||||
const root = path.join(dir, "dist-node", "assets")
|
||||
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
|
||||
return Object.fromEntries(
|
||||
(await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ try {
|
||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||
await driveSmoke()
|
||||
} catch (cause) {
|
||||
failure = cause
|
||||
} finally {
|
||||
@@ -111,6 +112,50 @@ function spawnService() {
|
||||
return process
|
||||
}
|
||||
|
||||
async function driveSmoke() {
|
||||
const name = "compiled-artifact"
|
||||
const drive = path.resolve(import.meta.dir, "../../drive/src/cli/index.ts")
|
||||
const driveEnv = {
|
||||
...env,
|
||||
DRIVE_REGISTRY_DIR: path.join(root, "drive-registry"),
|
||||
OPENCODE_DRIVE_KEEP_ARTIFACTS: "1",
|
||||
OPENCODE_DRIVE_MEDIA_DIR: path.join(root, "drive-media"),
|
||||
}
|
||||
let started = false
|
||||
try {
|
||||
await runDrive(["start", "--name", name, "--", binary], drive, driveEnv)
|
||||
started = true
|
||||
const screenshot = (
|
||||
await runDrive(
|
||||
["send", "--name", name, "--command.ui.screenshot", '{"name":"compiled-capture"}'],
|
||||
drive,
|
||||
driveEnv,
|
||||
)
|
||||
).trim()
|
||||
const bytes = Buffer.from(await Bun.file(screenshot).arrayBuffer())
|
||||
if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
||||
throw new Error("Compiled Drive bridge did not produce a PNG capture")
|
||||
} finally {
|
||||
if (started) await runDrive(["stop", "--name", name], drive, driveEnv)
|
||||
}
|
||||
}
|
||||
|
||||
async function runDrive(args: ReadonlyArray<string>, drive: string, driveEnv: Record<string, string | undefined>) {
|
||||
const child = Bun.spawn([process.execPath, drive, ...args], {
|
||||
cwd: root,
|
||||
env: driveEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (status !== 0) throw new Error(`opencode-drive ${args[0]} failed:\n${stderr}`)
|
||||
return stdout
|
||||
}
|
||||
|
||||
async function waitForRegistration() {
|
||||
const directory = path.join(root, "state", "opencode")
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
const forbidden = [
|
||||
"@napi-rs/canvas",
|
||||
"@fontsource/commit-mono",
|
||||
"@fontsource/noto-sans",
|
||||
"SimulationPng",
|
||||
"frontend/png",
|
||||
"Failed to register screenshot font",
|
||||
"commit-mono-latin-400-normal",
|
||||
"noto-sans-symbols-symbols-400-normal",
|
||||
"noto-sans-math-math-400-normal",
|
||||
"CommitMono-400-Regular.otf",
|
||||
"NotoSansSymbols.ttf",
|
||||
"packages/drive/src/recording/render",
|
||||
"src/frontend/png.ts",
|
||||
"skia.darwin-",
|
||||
"skia.linux-",
|
||||
"skia.win32-",
|
||||
]
|
||||
const overlap = Math.max(...forbidden.map((value) => value.length)) - 1
|
||||
|
||||
export async function verifyArtifact(target: string) {
|
||||
const files = await artifactFiles(target)
|
||||
if (files.length === 0) throw new Error(`Artifact contains no published files: ${target}`)
|
||||
for (const file of files) await scan(file)
|
||||
}
|
||||
|
||||
export function verifySimulationGraph(inputs: Iterable<string>) {
|
||||
const modules = Array.from(inputs, (input) => input.replaceAll("\\", "/"))
|
||||
const required = [
|
||||
"/packages/simulation/src/frontend/simulation.ts",
|
||||
"/packages/simulation/src/frontend/server.ts",
|
||||
"/packages/simulation/src/control-server.ts",
|
||||
]
|
||||
const missing = required.filter((input) => !modules.some((module) => module.endsWith(input)))
|
||||
if (missing.length > 0) throw new Error(`Build graph is missing simulation bridge inputs: ${missing.join(", ")}`)
|
||||
const leaked = modules.find(
|
||||
(module) =>
|
||||
module.includes("/packages/simulation/src/frontend/png.") || module.includes("/packages/drive/src/recording/"),
|
||||
)
|
||||
if (leaked) throw new Error(`Build graph contains Drive-only rendering input: ${leaked}`)
|
||||
}
|
||||
|
||||
async function artifactFiles(target: string): Promise<string[]> {
|
||||
if ((await stat(target)).isFile()) return [target]
|
||||
return (await collectFiles(target)).map((file) => path.join(target, file))
|
||||
}
|
||||
|
||||
async function scan(file: string) {
|
||||
let trailing = ""
|
||||
const reader = Bun.file(file).stream().getReader()
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) return
|
||||
const text = trailing + Buffer.from(chunk.value).toString("latin1")
|
||||
const leaked = forbidden.find((marker) => text.includes(marker))
|
||||
if (leaked) throw new Error(`Artifact file ${file} contains forbidden simulation payload: ${leaked}`)
|
||||
trailing = text.slice(-overlap)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const target = process.argv[2]
|
||||
if (!target) throw new Error("Usage: bun run script/verify-artifact.ts <file-or-directory>")
|
||||
await verifyArtifact(target)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
||||
import { verifySimulationGraph } from "./script/verify-artifact"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
@@ -48,6 +49,15 @@ function runtimeRequirePlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function simulationGraphPlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:simulation-graph",
|
||||
generateBundle() {
|
||||
verifySimulationGraph(this.getModuleIds())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fffNodePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
@@ -240,6 +250,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
simulationGraphPlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
@@ -255,6 +266,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||
},
|
||||
ssr: { noExternal: true },
|
||||
build: {
|
||||
@@ -264,7 +276,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
emptyOutDir: false,
|
||||
minify: true,
|
||||
rollupOptions: {
|
||||
external: [/^@opencode-ai\/simulation(?:\/|$)/],
|
||||
output: output("opencode.mjs", nodePrelude(input)),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as Schema from "effect/Schema"
|
||||
import { Frontend } from "../client/protocol.js"
|
||||
import { recordLog } from "../log.js"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import * as OpenCodeUi from "../driver/ui.js"
|
||||
import type { DriveCommand } from "./types.js"
|
||||
|
||||
export const commandInfo = {
|
||||
@@ -48,11 +50,11 @@ export const commandInfo = {
|
||||
description: "Finish recording and return the timeline path",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
Exclude<Frontend.Capability, "ui.click.semantic">,
|
||||
DriveCommand["operation"],
|
||||
{ readonly value: boolean | "optional"; readonly description: string }
|
||||
>
|
||||
|
||||
type CommandName = Exclude<Frontend.Capability, "ui.click.semantic">
|
||||
type CommandName = DriveCommand["operation"]
|
||||
|
||||
export function isCommandName(operation: string): operation is CommandName {
|
||||
return Object.hasOwn(commandInfo, operation)
|
||||
@@ -90,9 +92,14 @@ export class CommandBatchError extends Error {
|
||||
}
|
||||
|
||||
const callTimeout = 30_000
|
||||
const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) })
|
||||
|
||||
export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
|
||||
const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands)))
|
||||
export async function executeCommands(
|
||||
endpoint: string,
|
||||
commands: ReadonlyArray<DriveCommand>,
|
||||
options?: OpenCodeUi.Options,
|
||||
) {
|
||||
const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands, options)))
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
const reason = Cause.squash(exit.cause)
|
||||
throw reason instanceof CommandBatchError ? reason : new CommandBatchError([], reason)
|
||||
@@ -101,6 +108,7 @@ export async function executeCommands(endpoint: string, commands: ReadonlyArray<
|
||||
const executeBatch = Effect.fn("DriveCli.executeBatch")(function* (
|
||||
endpoint: string,
|
||||
commands: ReadonlyArray<DriveCommand>,
|
||||
options?: OpenCodeUi.Options,
|
||||
) {
|
||||
const connection = yield* SimulationConnector.ui(endpoint, {
|
||||
connectTimeout: callTimeout,
|
||||
@@ -111,7 +119,7 @@ const executeBatch = Effect.fn("DriveCli.executeBatch")(function* (
|
||||
)
|
||||
const results: Array<{ readonly command: string; readonly result: unknown }> = []
|
||||
for (const command of commands) {
|
||||
const result = yield* execute(connection, command).pipe(
|
||||
const result = yield* execute(connection, command, options).pipe(
|
||||
Effect.mapError((error) => new CommandBatchError(results, error)),
|
||||
)
|
||||
results.push({ command: command.operation, result })
|
||||
@@ -122,9 +130,17 @@ const executeBatch = Effect.fn("DriveCli.executeBatch")(function* (
|
||||
const execute = (
|
||||
connection: SimulationConnector.UiConnection,
|
||||
command: DriveCommand,
|
||||
options?: OpenCodeUi.Options,
|
||||
): Effect.Effect<unknown, SimulationError> =>
|
||||
Effect.suspend(() => {
|
||||
recordLog("INFO", `ui command ${command.operation} params=${command.value ?? "undefined"}`)
|
||||
if (command.operation === "ui.screenshot") {
|
||||
const params = Schema.decodeUnknownSync(ScreenshotParams)(
|
||||
command.value === undefined ? {} : JSON.parse(command.value),
|
||||
{ onExcessProperty: "error" },
|
||||
)
|
||||
return OpenCodeUi.make(connection, options).screenshot(params.name)
|
||||
}
|
||||
return dispatch(connection, decodeCommand(command))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
@@ -145,10 +161,12 @@ const execute = (
|
||||
function decodeCommand(command: DriveCommand): Frontend.Request {
|
||||
if (command.value === undefined && commandInfo[command.operation].value === true)
|
||||
throw new Error(`${command.operation} requires a value`)
|
||||
const operation = command.operation
|
||||
if (operation === "ui.screenshot") throw new Error("ui.screenshot must be decoded by Drive")
|
||||
return Frontend.decodeRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: command.operation,
|
||||
method: operation,
|
||||
...(command.value === undefined ? {} : { params: JSON.parse(command.value) }),
|
||||
},
|
||||
{ onExcessProperty: "error" },
|
||||
@@ -187,8 +205,6 @@ function dispatch(
|
||||
return connection.rpc["ui.click"](request.params)
|
||||
case "ui.resize":
|
||||
return connection.rpc["ui.resize"](request.params)
|
||||
case "ui.screenshot":
|
||||
return connection.rpc["ui.screenshot"](request.params)
|
||||
case "ui.capture":
|
||||
return connection.rpc["ui.capture"]()
|
||||
case "ui.state":
|
||||
|
||||
@@ -3,10 +3,17 @@ import type { SendOptions } from "./types.js"
|
||||
import { defaultPort } from "../client/index.js"
|
||||
import { resolveInstance, resolveVisibleInstance } from "../instance/registry.js"
|
||||
import { configureLogFile } from "../log.js"
|
||||
import { readInstanceMediaDirectory } from "../instance/media.js"
|
||||
|
||||
export async function send(options: SendOptions) {
|
||||
if (options.commands.length === 0) throw new Error("send requires at least one --command.ui.* flag")
|
||||
const result = await executeCommands(await resolveSendEndpoint(options.name), options.commands)
|
||||
const target = await resolveSendTarget(
|
||||
options.name,
|
||||
options.commands.some((command) => command.operation === "ui.screenshot"),
|
||||
)
|
||||
const result = await executeCommands(target.endpoint, options.commands, {
|
||||
screenshotDirectory: target.screenshotDirectory,
|
||||
})
|
||||
if (
|
||||
options.commands.length === 1 &&
|
||||
["ui.screenshot", "ui.matches", "ui.recording.finish"].includes(options.commands[0]?.operation ?? "")
|
||||
@@ -25,15 +32,29 @@ export async function send(options: SendOptions) {
|
||||
}
|
||||
|
||||
export async function resolveSendEndpoint(name?: string) {
|
||||
return (await resolveSendTarget(name)).endpoint
|
||||
}
|
||||
|
||||
async function resolveSendTarget(name?: string, screenshot = false) {
|
||||
if (name) {
|
||||
const manifest = await resolveInstance(name)
|
||||
configureLogFile(manifest.artifacts)
|
||||
return manifest.endpoints.ui
|
||||
return {
|
||||
endpoint: manifest.endpoints.ui,
|
||||
...(screenshot
|
||||
? { screenshotDirectory: await readInstanceMediaDirectory(manifest.artifacts, manifest.name) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
const manifest = await resolveVisibleInstance()
|
||||
if (manifest) {
|
||||
configureLogFile(manifest.artifacts)
|
||||
return manifest.endpoints.ui
|
||||
return {
|
||||
endpoint: manifest.endpoints.ui,
|
||||
...(screenshot
|
||||
? { screenshotDirectory: await readInstanceMediaDirectory(manifest.artifacts, manifest.name) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return `ws://127.0.0.1:${defaultPort}`
|
||||
return { endpoint: `ws://127.0.0.1:${defaultPort}` }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Frontend } from "../client/index.js"
|
||||
|
||||
export interface DriveCommand {
|
||||
readonly operation: Exclude<Frontend.Capability, "ui.click.semantic">
|
||||
readonly operation: Exclude<Frontend.Capability, "ui.click.semantic"> | "ui.screenshot"
|
||||
readonly value?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const make = Effect.fn("OpenCodeTui.make")(function* (
|
||||
(client) => client.close.pipe(Effect.catchCause((cause) => Effect.logError("OpenCode TUI cleanup failed", cause))),
|
||||
)
|
||||
const connection = yield* connector.ui(launched.endpoint, { compatibility })
|
||||
const ui = OpenCodeUi.make(connection)
|
||||
const ui = OpenCodeUi.make(connection, { screenshotDirectory: launched.media })
|
||||
yield* ui.waitFor((state) => state.focused.editor, {
|
||||
timeout: 30_000,
|
||||
interval: 50,
|
||||
|
||||
@@ -136,6 +136,7 @@ export {
|
||||
UiElementAmbiguousError,
|
||||
UiNodeAmbiguousError,
|
||||
UiPredicateError,
|
||||
UiScreenshotError,
|
||||
UiTimeoutError,
|
||||
UiWaitOptionsError,
|
||||
} from "./ui.js"
|
||||
@@ -146,6 +147,6 @@ export type { Recording, Tui, TuiLaunchError, TuiOptions, Tuis } from "./client.
|
||||
export type { Llm } from "./llm.js"
|
||||
export type { Target as OpenCodeTarget } from "./server.js"
|
||||
export type { OpenCode } from "./opencode.js"
|
||||
export type { Ui } from "./ui.js"
|
||||
export type { ScreenshotError, Ui } from "./ui.js"
|
||||
export type { Project, ProjectFileSystem, Setup, SetupContext } from "../project.js"
|
||||
export * from "./report.js"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { extname, join, resolve } from "node:path"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import type { Frontend } from "../client/protocol.js"
|
||||
|
||||
export async function renderScreenshot(frame: Frontend.CapturedFrame, directory: string, name?: string) {
|
||||
const filename = name ?? `screenshot-${crypto.randomUUID()}`
|
||||
if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
|
||||
throw new Error("screenshot name must not contain a path or extension")
|
||||
const { renderFrame } = await import("../recording/render.js")
|
||||
const output = resolve(directory)
|
||||
await mkdir(output, { recursive: true })
|
||||
const path = join(output, `${filename}.png`)
|
||||
await Bun.write(path, renderFrame(frame))
|
||||
return path
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { RpcClientError } from "effect/unstable/rpc"
|
||||
import { supportsCapability, type UiConnection } from "../simulation/connector.js"
|
||||
import { Frontend } from "../client/protocol.js"
|
||||
import type { SimulationRequestError } from "@opencode-ai/protocol/simulation"
|
||||
import { mediaDirectory } from "../instance/media.js"
|
||||
import { renderScreenshot } from "./screenshot.js"
|
||||
|
||||
export interface WaitOptions {
|
||||
/** Maximum wait in milliseconds. Defaults to 5,000. */
|
||||
@@ -71,9 +73,16 @@ export class UiPredicateError extends Schema.TaggedErrorClass<UiPredicateError>(
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class UiScreenshotError extends Schema.TaggedErrorClass<UiScreenshotError>()("UiScreenshotError", {
|
||||
cause: Schema.Defect(),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Options {
|
||||
/** Per-RPC timeout in milliseconds. Defaults to 30,000. */
|
||||
readonly requestTimeout?: number
|
||||
/** Directory where Drive writes locally rendered screenshots. */
|
||||
readonly screenshotDirectory?: string
|
||||
}
|
||||
|
||||
const RequestTimeout = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
@@ -81,6 +90,7 @@ const RequestTimeout = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
export type WaitError = UiTimeoutError | UiWaitOptionsError
|
||||
type RpcError = SimulationRequestError | RpcClientError.RpcClientError
|
||||
export type OperationError = RpcError | UiTimeoutError
|
||||
export type ScreenshotError = OperationError | UiScreenshotError
|
||||
export type SemanticOperationError = OperationError | UiCapabilityError
|
||||
|
||||
export interface Ui {
|
||||
@@ -88,7 +98,7 @@ export interface Ui {
|
||||
readonly snapshot: () => Effect.Effect<Frontend.SemanticSnapshot, SemanticOperationError>
|
||||
readonly capture: () => Effect.Effect<Frontend.CapturedFrame, OperationError>
|
||||
readonly matches: (text: string) => Effect.Effect<boolean, OperationError>
|
||||
readonly screenshot: (name?: string) => Effect.Effect<string, OperationError>
|
||||
readonly screenshot: (name?: string) => Effect.Effect<string, ScreenshotError>
|
||||
readonly type: (text: string) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly press: (key: string, modifiers?: Frontend.KeyModifiers) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly enter: () => Effect.Effect<Frontend.State, OperationError>
|
||||
@@ -172,9 +182,17 @@ export const make = (connection: UiConnection, options?: Options): Control => {
|
||||
})
|
||||
const capture = Effect.fn("Ui.capture")(() => call("capture", rpc["ui.capture"]()))
|
||||
const matches = Effect.fn("Ui.matches")((text: string) => call("matches", rpc["ui.matches"]({ text })))
|
||||
const screenshot = Effect.fn("Ui.screenshot")((name?: string) =>
|
||||
call("screenshot", rpc["ui.screenshot"](name === undefined ? undefined : { name })),
|
||||
)
|
||||
const screenshot = Effect.fn("Ui.screenshot")(function* (name?: string) {
|
||||
const frame = yield* capture()
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => renderScreenshot(frame, options?.screenshotDirectory ?? mediaDirectory(), name),
|
||||
catch: (cause) =>
|
||||
new UiScreenshotError({
|
||||
cause,
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
})
|
||||
})
|
||||
const finishRecording = Effect.fn("Ui.finishRecording")(() => call("finishRecording", rpc["ui.recording.finish"]()))
|
||||
const type = Effect.fn("Ui.type")((text: string) => call("type", rpc["ui.type"]({ text })))
|
||||
const press = Effect.fn("Ui.press")((key: string, modifiers?: Frontend.KeyModifiers) =>
|
||||
|
||||
@@ -7,3 +7,10 @@ export function mediaDirectory() {
|
||||
|
||||
export const runMediaDirectory = (artifacts: string, generation: number) =>
|
||||
join(mediaDirectory(), basename(resolve(artifacts)), `generation-${generation}`)
|
||||
|
||||
export async function readInstanceMediaDirectory(artifacts: string, name: string) {
|
||||
const value: unknown = await Bun.file(join(artifacts, "drive", `${name}.json`)).json()
|
||||
if (typeof value !== "object" || value === null || !("media" in value) || typeof value.media !== "string")
|
||||
throw new Error(`drive instance "${name}" has no media directory`)
|
||||
return value.media
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface Options {
|
||||
|
||||
export interface TuiProcess {
|
||||
readonly endpoint: string
|
||||
readonly media: string
|
||||
readonly process: Process.Running
|
||||
readonly recording?: RecordingPaths
|
||||
readonly close: Effect.Effect<void, OpenCodeInstanceError>
|
||||
@@ -173,6 +174,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
|
||||
`${JSON.stringify(
|
||||
{
|
||||
endpoints: manifestEndpoints,
|
||||
media,
|
||||
...(viewport ? { viewport } : {}),
|
||||
...(recording ? { recording: { timeline: recording.timeline } } : {}),
|
||||
},
|
||||
@@ -325,10 +327,10 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
|
||||
...value,
|
||||
pendingTuis: new Map(value.pendingTuis).set(name, tui),
|
||||
}))
|
||||
return { tui, tuiEndpoints, primary, recording }
|
||||
return { tui, tuiEndpoints, primary, recording, media }
|
||||
}),
|
||||
)
|
||||
const { tui, tuiEndpoints, primary, recording } = pending
|
||||
const { tui, tuiEndpoints, primary, recording, media: tuiMedia } = pending
|
||||
const removePending = lock.withPermit(
|
||||
Ref.update(state, (value) => {
|
||||
if (value.pendingTuis.get(name) !== tui) return value
|
||||
@@ -384,6 +386,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
|
||||
})
|
||||
return {
|
||||
endpoint: tuiEndpoints.ui,
|
||||
media: tuiMedia,
|
||||
process: tui,
|
||||
recording,
|
||||
close,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
baselineOffset,
|
||||
drawBlockGlyph,
|
||||
} from "../frame/index.js"
|
||||
import type { Frontend } from "../client/protocol.js"
|
||||
import type { CapturedFrame } from "./types.js"
|
||||
|
||||
export { CellHeight, CellWidth } from "../frame/index.js"
|
||||
@@ -47,8 +48,10 @@ for (const [file, family] of [
|
||||
if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register capture symbol font: ${path}`)
|
||||
}
|
||||
|
||||
function color(rgb: number, alpha = 1) {
|
||||
return `rgba(${(rgb >> 16) & 255}, ${(rgb >> 8) & 255}, ${rgb & 255}, ${alpha})`
|
||||
function color(value: number | Frontend.Color, opacity = 1) {
|
||||
if (typeof value === "number")
|
||||
return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${opacity})`
|
||||
return `rgba(${value[0]}, ${value[1]}, ${value[2]}, ${(value[3] / 255) * opacity})`
|
||||
}
|
||||
|
||||
export interface RenderFrameOptions {
|
||||
@@ -57,7 +60,7 @@ export interface RenderFrameOptions {
|
||||
readonly header?: string
|
||||
}
|
||||
|
||||
export function renderFrame(frame: CapturedFrame, options: RenderFrameOptions = {}): Buffer {
|
||||
export function renderFrame(frame: CapturedFrame | Frontend.CapturedFrame, options: RenderFrameOptions = {}): Buffer {
|
||||
const cols = Math.max(frame.cols, options.cols ?? frame.cols)
|
||||
const rows = Math.max(frame.rows, options.rows ?? frame.rows)
|
||||
const headerHeight = options.header ? 40 : 0
|
||||
@@ -118,12 +121,13 @@ export function renderFrame(frame: CapturedFrame, options: RenderFrameOptions =
|
||||
}
|
||||
})
|
||||
|
||||
if (frame.cursor.visible && frame.cursor.row >= 0 && frame.cursor.row < frame.rows) {
|
||||
const cursor = frame.cursor
|
||||
if ("visible" in cursor && cursor.visible && cursor.row >= 0 && cursor.row < frame.rows) {
|
||||
context.strokeStyle = "#d8d8d8"
|
||||
context.lineWidth = 2
|
||||
context.strokeRect(
|
||||
frame.cursor.col * CellWidth + 1,
|
||||
headerHeight + frame.cursor.row * CellHeight + 1,
|
||||
cursor.col * CellWidth + 1,
|
||||
headerHeight + cursor.row * CellHeight + 1,
|
||||
CellWidth - 2,
|
||||
CellHeight - 2,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, readdir, realpath, rm } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, readdir, realpath, rename, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, dirname, join, resolve } from "node:path"
|
||||
import {
|
||||
@@ -186,6 +186,17 @@ describe("opencode-drive", () => {
|
||||
expect(manifest.endpoints.ui).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect(manifest.endpoints.backend).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
|
||||
const runtimeManifest = join(manifest.artifacts, "drive", `${name}.json`)
|
||||
const hiddenRuntimeManifest = `${runtimeManifest}.hidden`
|
||||
await rename(runtimeManifest, hiddenRuntimeManifest)
|
||||
const batch = spawn(
|
||||
["send", "--name", name, "--command.ui.state", "--command.ui.matches", '{"text":"Fake OpenCode"}'],
|
||||
root,
|
||||
)
|
||||
expect(await batch.exited).toBe(0)
|
||||
expect(await new Response(batch.stdout).text()).toBe("success\n")
|
||||
await rename(hiddenRuntimeManifest, runtimeManifest)
|
||||
|
||||
const state = spawn(["send", "--name", name, "--command.ui.state"], root)
|
||||
expect(await state.exited).toBe(0)
|
||||
expect(JSON.parse(await new Response(state.stdout).text()).focused.editor).toBe(true)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "@effect/vitest"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, type Types } from "effect"
|
||||
import * as OpenCodeUi from "../../src/driver/ui.js"
|
||||
import * as SimulationConnector from "../../src/simulation/connector.js"
|
||||
import { sendError, sendResult, startTransportPeer } from "../simulation/transport-peer.js"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas"
|
||||
|
||||
const editor = {
|
||||
id: "prompt",
|
||||
@@ -58,6 +62,15 @@ const frame = {
|
||||
],
|
||||
}
|
||||
|
||||
type ScreenshotFailure = Effect.Effect.Error<ReturnType<OpenCodeUi.Ui["screenshot"]>>
|
||||
const operationErrorExcludesScreenshotError: Types.Equals<
|
||||
Extract<OpenCodeUi.OperationError, OpenCodeUi.UiScreenshotError>,
|
||||
never
|
||||
> = true
|
||||
const screenshotErrorIsSpecific: Types.Equals<ScreenshotFailure, OpenCodeUi.ScreenshotError> = true
|
||||
void operationErrorExcludesScreenshotError
|
||||
void screenshotErrorIsSpecific
|
||||
|
||||
describe("OpenCodeUi", () => {
|
||||
it.live("captures a normalized terminal frame", () => {
|
||||
const peer = startTransportPeer(({ request, socket }) => sendResult(socket, request, frame))
|
||||
@@ -78,10 +91,6 @@ describe("OpenCodeUi", () => {
|
||||
sendResult(socket, request, matchCalls > 1)
|
||||
return
|
||||
}
|
||||
if (request.method === "ui.screenshot") {
|
||||
sendResult(socket, request, "/tmp/home.png")
|
||||
return
|
||||
}
|
||||
sendResult(socket, request, state)
|
||||
})
|
||||
|
||||
@@ -93,7 +102,6 @@ describe("OpenCodeUi", () => {
|
||||
expect(yield* ui.submit("hello")).toEqual(state)
|
||||
expect(yield* ui.press("escape", { ctrl: true })).toEqual(state)
|
||||
expect(yield* ui.click(3)).toEqual(state)
|
||||
expect(yield* ui.screenshot("home")).toBe("/tmp/home.png")
|
||||
expect(yield* ui.waitFor("ready", { timeout: 1_000, interval: 1 })).toEqual(state)
|
||||
expect(yield* ui.getElement({ editor: true })).toEqual(editor)
|
||||
|
||||
@@ -121,8 +129,8 @@ describe("OpenCodeUi", () => {
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
method: "ui.screenshot",
|
||||
params: { name: "home" },
|
||||
method: "ui.matches",
|
||||
params: { text: "ready" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
@@ -130,18 +138,60 @@ describe("OpenCodeUi", () => {
|
||||
method: "ui.matches",
|
||||
params: { text: "ready" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 8,
|
||||
method: "ui.matches",
|
||||
params: { text: "ready" },
|
||||
},
|
||||
{ jsonrpc: "2.0", id: 8, method: "ui.state" },
|
||||
{ jsonrpc: "2.0", id: 9, method: "ui.state" },
|
||||
{ jsonrpc: "2.0", id: 10, method: "ui.state" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it.live("renders negotiated screenshots from captured frames inside Drive", () => {
|
||||
const peer = startTransportPeer(({ request, socket }) => sendResult(socket, request, frame))
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => peer.stop()))
|
||||
const directory = yield* Effect.promise(() => mkdtemp(join(tmpdir(), "opencode-drive-screenshot-")))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => rm(directory, { recursive: true, force: true })))
|
||||
const connection = yield* SimulationConnector.ui(peer.url)
|
||||
const path = yield* OpenCodeUi.make(connection, { screenshotDirectory: directory }).screenshot("home")
|
||||
|
||||
expect(path).toBe(join(directory, "home.png"))
|
||||
const bytes = yield* Effect.promise(() => Bun.file(path).arrayBuffer())
|
||||
expect(Buffer.from(bytes).subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
expect(peer.received.map(({ request }) => request)).toEqual([{ jsonrpc: "2.0", id: 1, method: "ui.capture" }])
|
||||
})
|
||||
})
|
||||
|
||||
it.live("preserves transparent foreground and background colors in screenshots", () => {
|
||||
const transparent = {
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cursor: [0, 0] as const,
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{ text: "█", fg: [0, 255, 0, 0] as const, bg: [255, 0, 0, 255] as const, attributes: 0, width: 1 },
|
||||
{ text: " ", fg: [255, 255, 255, 255] as const, bg: [0, 0, 255, 0] as const, attributes: 0, width: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const peer = startTransportPeer(({ request, socket }) => sendResult(socket, request, transparent))
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => peer.stop()))
|
||||
const directory = yield* Effect.promise(() => mkdtemp(join(tmpdir(), "opencode-drive-alpha-")))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => rm(directory, { recursive: true, force: true })))
|
||||
const connection = yield* SimulationConnector.ui(peer.url)
|
||||
const path = yield* OpenCodeUi.make(connection, { screenshotDirectory: directory }).screenshot("alpha")
|
||||
const image = yield* Effect.promise(() => loadImage(path))
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(image, 0, 0)
|
||||
|
||||
expect(Array.from(context.getImageData(5, 10, 1, 1).data)).toEqual([255, 0, 0, 255])
|
||||
expect(Array.from(context.getImageData(15, 10, 1, 1).data)).toEqual([8, 8, 8, 255])
|
||||
})
|
||||
})
|
||||
|
||||
it.live("selects and clicks semantic UI nodes", () => {
|
||||
let snapshotCalls = 0
|
||||
const peer = startTransportPeer(({ request, socket }) => {
|
||||
|
||||
@@ -301,10 +301,6 @@ function frontend(method: string, params: unknown) {
|
||||
],
|
||||
}
|
||||
}
|
||||
if (method === "ui.screenshot") {
|
||||
const name = isRecord(params) && typeof params.name === "string" ? params.name : `screenshot-${crypto.randomUUID()}`
|
||||
return `${process.env.OPENCODE_DRIVE_MEDIA_DIR}/${name}.png`
|
||||
}
|
||||
if (method === "ui.recording.finish") {
|
||||
if (!drive.recording) throw new Error("recording is not enabled")
|
||||
return drive.recording.timeline
|
||||
|
||||
@@ -168,7 +168,7 @@ test("joins rendered frames horizontally", async () => {
|
||||
})
|
||||
|
||||
test("renders the canonical OpenCode symbol set with the fallback font", async () => {
|
||||
const symbols = [..."△⇆⊙⚙✱↳◌◈⟳▸▾■⬝⬥⬩⬪"]
|
||||
const symbols = [..."△⇆⊙⚙✱↳◌◈⟳▸▾■⬝⬥⬩⬪⠹"]
|
||||
const image = await loadImage(
|
||||
renderFrame({
|
||||
cols: symbols.length,
|
||||
@@ -212,6 +212,30 @@ test("renders the canonical OpenCode symbol set with the fallback font", async (
|
||||
expect(new Set(masks).size).toBe(symbols.length)
|
||||
})
|
||||
|
||||
test("draws heavy vertical box elements continuously across cell boundaries", async () => {
|
||||
const image = await loadImage(
|
||||
renderFrame({
|
||||
cols: 1,
|
||||
rows: 2,
|
||||
cursor: { row: 0, col: 0, visible: false },
|
||||
lines: [
|
||||
{ spans: [{ text: "┃", width: 1, fg: 0xffffff, bg: 0x000000, attributes: 0 }] },
|
||||
{ spans: [{ text: "╹", width: 1, fg: 0xffffff, bg: 0x000000, attributes: 0 }] },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(image, 0, 0)
|
||||
|
||||
expect(Array.from(context.getImageData(4, 0, 2, 30).data)).toEqual(
|
||||
Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(),
|
||||
)
|
||||
expect(Array.from(context.getImageData(4, 30, 2, 10).data)).toEqual(
|
||||
Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(),
|
||||
)
|
||||
})
|
||||
|
||||
test("accepts valid capture font overrides", async () => {
|
||||
const font = new URL("../../assets/fonts/commit-mono/CommitMono-400-Regular.otf", import.meta.url)
|
||||
const child = renderImport({ OPENCODE_DRIVE_FONT: fileURLToPath(font) })
|
||||
|
||||
@@ -18,11 +18,6 @@ describe("OpenCode Effect RPC compatibility protocol", () => {
|
||||
sendError(socket, request, "match failed")
|
||||
return
|
||||
}
|
||||
if (request.method === "ui.screenshot") {
|
||||
const params = request.params as { readonly name?: string } | undefined
|
||||
sendResult(socket, request, `/tmp/${params?.name ?? "screen"}.png`)
|
||||
return
|
||||
}
|
||||
if (request.method === "ui.state")
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
@@ -42,8 +37,6 @@ describe("OpenCode Effect RPC compatibility protocol", () => {
|
||||
const client = yield* RpcClient.make(UiRpcs).pipe(Effect.provideService(RpcClient.Protocol, protocol))
|
||||
|
||||
expect(yield* client["ui.state"]()).toEqual(state)
|
||||
expect(yield* client["ui.screenshot"](undefined)).toBe("/tmp/screen.png")
|
||||
expect(yield* client["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png")
|
||||
expect(yield* client["ui.press"]({ key: "right" })).toEqual(state)
|
||||
expect(yield* client["ui.press"]({ key: "down", modifiers: { meta: true } })).toEqual(state)
|
||||
expect(yield* client["ui.press"]({ key: "tab", modifiers: { ctrl: true } })).toEqual(state)
|
||||
@@ -64,35 +57,24 @@ describe("OpenCode Effect RPC compatibility protocol", () => {
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: firstId + 1,
|
||||
method: "ui.screenshot",
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: firstId + 2,
|
||||
method: "ui.screenshot",
|
||||
params: { name: "home" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: firstId + 3,
|
||||
method: "ui.press",
|
||||
params: { key: "\u001b[C" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: firstId + 4,
|
||||
id: firstId + 2,
|
||||
method: "ui.press",
|
||||
params: { key: "\u001b[1;3B" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: firstId + 5,
|
||||
id: firstId + 3,
|
||||
method: "ui.press",
|
||||
params: { key: "\u001b[9;5u" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: firstId + 6,
|
||||
id: firstId + 4,
|
||||
method: "ui.matches",
|
||||
params: { text: "fail" },
|
||||
},
|
||||
|
||||
@@ -31,10 +31,6 @@ describe("OpenCode simulation RPC contracts", () => {
|
||||
)
|
||||
return Effect.succeed(true)
|
||||
},
|
||||
"ui.screenshot": (payload) => {
|
||||
calls.push({ method: "ui.screenshot", payload })
|
||||
return Effect.succeed(`/tmp/${payload?.name ?? "screen"}.png`)
|
||||
},
|
||||
"ui.recording.finish": (payload) => {
|
||||
calls.push({ method: "ui.recording.finish", payload })
|
||||
return Effect.succeed("/tmp/recording.jsonl")
|
||||
@@ -62,8 +58,6 @@ describe("OpenCode simulation RPC contracts", () => {
|
||||
code: -32000,
|
||||
message: "match failed",
|
||||
})
|
||||
expect(yield* client["ui.screenshot"](undefined)).toBe("/tmp/screen.png")
|
||||
expect(yield* client["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png")
|
||||
expect(yield* client["ui.recording.finish"]()).toBe("/tmp/recording.jsonl")
|
||||
expect(yield* client["ui.type"]({ text: "hello" })).toEqual(state)
|
||||
|
||||
@@ -71,8 +65,6 @@ describe("OpenCode simulation RPC contracts", () => {
|
||||
{ method: "ui.state", payload: undefined },
|
||||
{ method: "ui.matches", payload: { text: "ready" } },
|
||||
{ method: "ui.matches", payload: { text: "fail" } },
|
||||
{ method: "ui.screenshot", payload: undefined },
|
||||
{ method: "ui.screenshot", payload: { name: "home" } },
|
||||
{ method: "ui.recording.finish", payload: undefined },
|
||||
{ method: "ui.type", payload: { text: "hello" } },
|
||||
])
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, test } from "@effect/vitest"
|
||||
import { Effect } from "effect"
|
||||
import { Frontend } from "../../src/client/index.js"
|
||||
import * as SimulationConnector from "../../src/simulation/connector.js"
|
||||
import { sendError, sendResult, startTransportPeer } from "./transport-peer.js"
|
||||
import { sendResult, startTransportPeer } from "./transport-peer.js"
|
||||
|
||||
const state: Frontend.State = {
|
||||
focused: { renderable: 1, editor: true },
|
||||
@@ -26,15 +26,6 @@ describe("OpenCode UI simulation transport", () => {
|
||||
sendResult(socket, request, true)
|
||||
return
|
||||
}
|
||||
if (request.method === "ui.screenshot") {
|
||||
const params = request.params as { readonly name?: string } | undefined
|
||||
if (params?.name === "fail") {
|
||||
sendError(socket, request, "screenshot failed")
|
||||
return
|
||||
}
|
||||
sendResult(socket, request, `/tmp/${params?.name ?? "screenshot"}.png`)
|
||||
return
|
||||
}
|
||||
if (request.method === "ui.recording.finish") {
|
||||
sendResult(socket, request, "/tmp/recording.jsonl")
|
||||
return
|
||||
@@ -47,8 +38,6 @@ describe("OpenCode UI simulation transport", () => {
|
||||
expect(yield* rpc["ui.state"]()).toEqual(state)
|
||||
expect(yield* rpc["ui.snapshot"]()).toEqual(snapshot)
|
||||
expect(yield* rpc["ui.matches"]({ text: "needle" })).toBe(true)
|
||||
expect(yield* rpc["ui.screenshot"](undefined)).toBe("/tmp/screenshot.png")
|
||||
expect(yield* rpc["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png")
|
||||
expect(yield* rpc["ui.recording.finish"]()).toBe("/tmp/recording.jsonl")
|
||||
expect(yield* rpc["ui.type"]({ text: "hello" })).toEqual(state)
|
||||
expect(yield* rpc["ui.press"]({ key: "x" })).toEqual(state)
|
||||
@@ -60,13 +49,6 @@ describe("OpenCode UI simulation transport", () => {
|
||||
expect(yield* rpc["ui.click"]({ target: 7, x: 3, y: 2 })).toEqual(state)
|
||||
expect(yield* rpc["ui.resize"]({ cols: 120, rows: 40 })).toEqual(state)
|
||||
|
||||
const error = yield* rpc["ui.screenshot"]({ name: "fail" }).pipe(Effect.flip)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "SimulationRequestError",
|
||||
message: "screenshot failed",
|
||||
method: "ui.screenshot",
|
||||
})
|
||||
|
||||
expect(peer.received.map(({ request }) => request)).toEqual([
|
||||
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
|
||||
{ jsonrpc: "2.0", id: 2, method: "ui.snapshot" },
|
||||
@@ -76,69 +58,56 @@ describe("OpenCode UI simulation transport", () => {
|
||||
method: "ui.matches",
|
||||
params: { text: "needle" },
|
||||
},
|
||||
{ jsonrpc: "2.0", id: 4, method: "ui.screenshot" },
|
||||
{ jsonrpc: "2.0", id: 4, method: "ui.recording.finish" },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "ui.screenshot",
|
||||
params: { name: "home" },
|
||||
},
|
||||
{ jsonrpc: "2.0", id: 6, method: "ui.recording.finish" },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 7,
|
||||
method: "ui.type",
|
||||
params: { text: "hello" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 8,
|
||||
id: 6,
|
||||
method: "ui.press",
|
||||
params: { key: "x" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 9,
|
||||
id: 7,
|
||||
method: "ui.press",
|
||||
params: { key: "x", modifiers: { ctrl: true, shift: false } },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 10,
|
||||
id: 8,
|
||||
method: "ui.press",
|
||||
params: { key: "escape" },
|
||||
},
|
||||
{ jsonrpc: "2.0", id: 11, method: "ui.enter" },
|
||||
{ jsonrpc: "2.0", id: 9, method: "ui.enter" },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 12,
|
||||
id: 10,
|
||||
method: "ui.arrow",
|
||||
params: { direction: "left" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 13,
|
||||
id: 11,
|
||||
method: "ui.focus",
|
||||
params: { target: 7 },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 14,
|
||||
id: 12,
|
||||
method: "ui.click",
|
||||
params: { target: 7, x: 3, y: 2 },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 15,
|
||||
id: 13,
|
||||
method: "ui.resize",
|
||||
params: { cols: 120, rows: 40 },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 16,
|
||||
method: "ui.screenshot",
|
||||
params: { name: "fail" },
|
||||
},
|
||||
])
|
||||
|
||||
for (const { request } of peer.received) expect(Frontend.decodeRequest(request)).toEqual(request)
|
||||
|
||||
@@ -175,7 +175,6 @@ export namespace Frontend {
|
||||
"ui.click.semantic",
|
||||
"ui.resize",
|
||||
"ui.matches",
|
||||
"ui.screenshot",
|
||||
"ui.state",
|
||||
"ui.snapshot",
|
||||
"ui.capture",
|
||||
@@ -279,9 +278,6 @@ export namespace Frontend {
|
||||
})
|
||||
export interface SemanticSnapshot extends Schema.Schema.Type<typeof SemanticSnapshot> {}
|
||||
|
||||
export const Screenshot = Schema.String
|
||||
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
|
||||
|
||||
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
|
||||
export type Color = Schema.Schema.Type<typeof Color>
|
||||
|
||||
@@ -311,9 +307,6 @@ export namespace Frontend {
|
||||
export const Matches = Schema.Boolean
|
||||
export type Matches = Schema.Schema.Type<typeof Matches>
|
||||
|
||||
export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) })
|
||||
export interface ScreenshotParams extends Schema.Schema.Type<typeof ScreenshotParams> {}
|
||||
|
||||
export const TypeParams = Schema.Struct({ text: Schema.String })
|
||||
export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
|
||||
|
||||
@@ -354,11 +347,6 @@ export namespace Frontend {
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.resize"), params: ResizeParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.matches"), params: MatchesParams }),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literal("ui.screenshot"),
|
||||
params: Schema.optional(ScreenshotParams),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literals(["ui.enter", "ui.state", "ui.snapshot", "ui.recording.finish"]),
|
||||
@@ -606,10 +594,6 @@ export const UiRpcs = RpcGroup.make(
|
||||
request("ui.snapshot", { success: Frontend.SemanticSnapshot }),
|
||||
request("ui.capture", { success: Frontend.CapturedFrame }),
|
||||
request("ui.matches", { payload: Frontend.MatchesParams, success: Frontend.Matches }),
|
||||
request("ui.screenshot", {
|
||||
payload: Schema.UndefinedOr(Frontend.ScreenshotParams),
|
||||
success: Frontend.Screenshot,
|
||||
}),
|
||||
request("ui.recording.finish", { success: Frontend.RecordingFinish }),
|
||||
request("ui.type", { payload: Frontend.TypeParams, success: Frontend.State }),
|
||||
request("ui.press", { payload: Frontend.PressParams, success: Frontend.State }),
|
||||
|
||||
@@ -17,22 +17,19 @@
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
declare module "*.woff2" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
@@ -332,9 +332,7 @@ function handle(
|
||||
fibers,
|
||||
driver.requests.pipe(
|
||||
Stream.runForEach((invocation) =>
|
||||
Effect.sync(() => {
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: invocation }))
|
||||
}),
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: invocation })),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -405,10 +403,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
|
||||
socket: ControlSocket,
|
||||
method: "tool.invocation" | "tool.cancel",
|
||||
params: SimulationProtocol.Backend.ToolInvocation | SimulationProtocol.Backend.ToolCancellation,
|
||||
) =>
|
||||
Effect.sync(() => {
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", method, params }))
|
||||
})
|
||||
) => socket.send(JSON.stringify({ jsonrpc: "2.0", method, params }))
|
||||
|
||||
const requireController = (socket: ControlSocket) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Fiber, Queue, Stream } from "effect"
|
||||
import { Effect, Fiber, FiberSet, Queue, Stream } from "effect"
|
||||
import { SimulationProtocol } from "./protocol"
|
||||
|
||||
export interface Server {
|
||||
@@ -15,7 +15,12 @@ export interface SocketData {
|
||||
closed?: true
|
||||
}
|
||||
|
||||
export type Socket = Bun.ServerWebSocket<SocketData>
|
||||
export interface Socket {
|
||||
readonly data: SocketData
|
||||
readonly send: (message: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
const maxOutboundBytes = 64 * 1024 * 1024
|
||||
|
||||
export function start<RequestType extends Request, Error, Services>(options: {
|
||||
readonly endpoint: string
|
||||
@@ -27,7 +32,6 @@ export function start<RequestType extends Request, Error, Services>(options: {
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const messages = yield* Queue.bounded<{ readonly socket: Socket; readonly input: string }>(256)
|
||||
const closures = yield* Queue.unbounded<Socket>()
|
||||
yield* Stream.fromQueue(messages).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
options.decode(message.input).pipe(
|
||||
@@ -40,42 +44,133 @@ export function start<RequestType extends Request, Error, Services>(options: {
|
||||
),
|
||||
),
|
||||
Effect.catch((error) => send(message.socket, SimulationProtocol.JsonRpc.failure(undefined, error))),
|
||||
Effect.catchCause((cause) => Effect.logWarning(`${options.label}: request failed`, cause)),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Stream.fromQueue(closures).pipe(
|
||||
Stream.runForEach((socket) => options.close?.(socket) ?? Effect.void),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const url = yield* Effect.try({ try: () => new URL(options.endpoint), catch: (cause) => cause })
|
||||
const websocket = yield* Effect.promise(() => import("ws"))
|
||||
const runPromise = yield* FiberSet.makeRuntimePromise<Services, void, never>()
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve<SocketData>({
|
||||
hostname: url.hostname,
|
||||
port: Number(url.port),
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: options.data() })) return undefined
|
||||
return new Response(options.label, { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
close(socket) {
|
||||
socket.data.closed = true
|
||||
Queue.offerUnsafe(closures, socket)
|
||||
},
|
||||
message(socket, message) {
|
||||
const input = typeof message === "string" ? message : message.toString()
|
||||
if (Queue.offerUnsafe(messages, { socket, input })) return
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
SimulationProtocol.JsonRpc.failure(undefined, new Error("Simulation control queue is full")),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
}),
|
||||
Effect.tryPromise(
|
||||
() =>
|
||||
new Promise<{
|
||||
readonly close: () => Promise<void>
|
||||
}>((resolve, reject) => {
|
||||
const server = new websocket.WebSocketServer({ host: url.hostname, port: Number(url.port) })
|
||||
const sockets = new Map<
|
||||
InstanceType<typeof websocket.WebSocket>,
|
||||
{ socket: Socket; cleanup: () => Promise<void> }
|
||||
>()
|
||||
const report = (scope: string, cause: unknown) =>
|
||||
void runPromise(Effect.logWarning(`${options.label}: ${scope} error`, cause))
|
||||
const onServerError = (cause: Error) => report("server", cause)
|
||||
const onStartupError = (cause: Error) => {
|
||||
server.off("listening", onListening)
|
||||
server.on("error", onServerError)
|
||||
reject(cause)
|
||||
}
|
||||
const onListening = () => {
|
||||
server.off("error", onStartupError)
|
||||
server.on("error", onServerError)
|
||||
resolve({
|
||||
close: async () => {
|
||||
const accepted = Array.from(sockets.entries())
|
||||
accepted.forEach(([connection, record]) => {
|
||||
record.socket.data.closed = true
|
||||
connection.terminate()
|
||||
})
|
||||
await Promise.all([
|
||||
new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((cause) => (cause ? rejectClose(cause) : resolveClose()))
|
||||
// Bun releases the listener but does not invoke ws' close callback after an upgraded socket.
|
||||
queueMicrotask(() => {
|
||||
if (server.address() === null) resolveClose()
|
||||
})
|
||||
}),
|
||||
Promise.all(accepted.map(([, record]) => record.cleanup())),
|
||||
])
|
||||
server.off("error", onServerError)
|
||||
},
|
||||
})
|
||||
}
|
||||
server.once("listening", onListening)
|
||||
server.once("error", onStartupError)
|
||||
server.on("connection", (connection) => {
|
||||
let pendingBytes = 0
|
||||
let outbound = Promise.resolve()
|
||||
let cleanup: Promise<void> | undefined
|
||||
const socket: Socket = {
|
||||
data: options.data(),
|
||||
send: (message) =>
|
||||
Effect.tryPromise({
|
||||
try: () => {
|
||||
const bytes = Buffer.byteLength(message)
|
||||
if (bytes > maxOutboundBytes || pendingBytes + bytes > maxOutboundBytes)
|
||||
return Promise.reject(new Error(`Simulation outbound queue exceeds ${maxOutboundBytes} bytes`))
|
||||
pendingBytes += bytes
|
||||
const current = outbound.then(
|
||||
() =>
|
||||
new Promise<void>((resolveSend, rejectSend) => {
|
||||
if (connection.readyState !== websocket.WebSocket.OPEN) {
|
||||
rejectSend(new Error("Simulation control socket is not open"))
|
||||
return
|
||||
}
|
||||
connection.send(message, (cause) => (cause ? rejectSend(cause) : resolveSend()))
|
||||
}),
|
||||
)
|
||||
outbound = current.catch(() => undefined)
|
||||
return current.finally(() => {
|
||||
pendingBytes -= bytes
|
||||
})
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.sync(() => {
|
||||
connection.terminate()
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
),
|
||||
}
|
||||
const close = () => {
|
||||
socket.data.closed = true
|
||||
cleanup ??= runPromise(options.close?.(socket) ?? Effect.void).finally(() => sockets.delete(connection))
|
||||
return cleanup
|
||||
}
|
||||
sockets.set(connection, { socket, cleanup: close })
|
||||
connection.on("close", () => void close())
|
||||
connection.on("error", (cause) => {
|
||||
report("socket", cause)
|
||||
connection.terminate()
|
||||
})
|
||||
connection.on("message", (message) => {
|
||||
const input = Array.isArray(message)
|
||||
? Buffer.concat(message).toString()
|
||||
: message instanceof ArrayBuffer
|
||||
? Buffer.from(message).toString()
|
||||
: message.toString()
|
||||
if (Queue.offerUnsafe(messages, { socket, input })) return
|
||||
void runPromise(
|
||||
socket
|
||||
.send(
|
||||
JSON.stringify(
|
||||
SimulationProtocol.JsonRpc.failure(undefined, new Error("Simulation control queue is full")),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning(`${options.label}: queue rejection failed`, cause),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
({ close }) => Effect.promise(close),
|
||||
)
|
||||
return { url: options.endpoint } satisfies Server
|
||||
})
|
||||
@@ -83,9 +178,7 @@ export function start<RequestType extends Request, Error, Services>(options: {
|
||||
|
||||
function send(socket: Socket, response: SimulationProtocol.JsonRpc.Response | undefined) {
|
||||
if (!response) return Effect.void
|
||||
return Effect.sync(() => {
|
||||
socket.send(JSON.stringify(response))
|
||||
})
|
||||
return socket.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
export * as SimulationControlServer from "./control-server"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { tmpdir } from "node:os"
|
||||
import { extname, join, resolve } from "node:path"
|
||||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import {
|
||||
createMockKeys,
|
||||
@@ -9,7 +7,7 @@ import {
|
||||
type MockInput,
|
||||
type MockMouse,
|
||||
} from "@opentui/core/testing"
|
||||
import { Config, Effect, FileSystem, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SimulationProtocol } from "../protocol"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationSemantics } from "./semantics"
|
||||
@@ -167,25 +165,6 @@ export const capture = Effect.fn("SimulationActions.capture")(function* (harness
|
||||
} satisfies SimulationProtocol.Frontend.CapturedFrame
|
||||
})
|
||||
|
||||
export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) {
|
||||
const filename = name ?? `screenshot-${crypto.randomUUID()}`
|
||||
if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
|
||||
return yield* Effect.fail(new Error("screenshot name must not contain a path or extension"))
|
||||
yield* Effect.tryPromise(() => harness.renderOnce())
|
||||
const { SimulationPng } = yield* Effect.promise(() => import("./png"))
|
||||
const image = SimulationPng.screenshot(harness.renderer)
|
||||
const directory = resolve(
|
||||
yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe(
|
||||
Config.withDefault(join(tmpdir(), "opencode-drive", "output")),
|
||||
),
|
||||
)
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
const path = join(directory, `${filename}.png`)
|
||||
yield* fs.writeFile(path, image.data)
|
||||
return path
|
||||
})
|
||||
|
||||
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
|
||||
switch (action.type) {
|
||||
case "ui.type":
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/// <reference path="../assets.d.ts" />
|
||||
import { GlobalFonts, createCanvas, type SKRSContext2D } from "@napi-rs/canvas"
|
||||
import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core"
|
||||
import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-normal.woff2" with { type: "file" }
|
||||
import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" }
|
||||
import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" }
|
||||
import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" }
|
||||
import symbolFont from "@fontsource/noto-sans-symbols/files/noto-sans-symbols-symbols-400-normal.woff2" with { type: "file" }
|
||||
import symbolFont2 from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-symbols-400-normal.woff2" with { type: "file" }
|
||||
import brailleFont from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-braille-400-normal.woff2" with { type: "file" }
|
||||
import mathFont from "@fontsource/noto-sans-math/files/noto-sans-math-math-400-normal.woff2" with { type: "file" }
|
||||
|
||||
const CellWidth = 10
|
||||
const CellHeight = 20
|
||||
const FontSize = 16
|
||||
const FontFamily = "OpenCode Mono"
|
||||
const SymbolFontFamily = "OpenCode Symbols"
|
||||
const SymbolFontFamily2 = "OpenCode Symbols 2"
|
||||
const MathFontFamily = "OpenCode Math"
|
||||
const FontStack = `"${FontFamily}", "${SymbolFontFamily}", "${SymbolFontFamily2}", "${MathFontFamily}"`
|
||||
|
||||
for (const [file, family] of [
|
||||
...[regularFont, boldFont, italicFont, boldItalicFont].map((file) => [file, FontFamily] as const),
|
||||
[symbolFont, SymbolFontFamily],
|
||||
[symbolFont2, SymbolFontFamily2],
|
||||
[brailleFont, SymbolFontFamily2],
|
||||
[mathFont, MathFontFamily],
|
||||
] as const) {
|
||||
const font = Buffer.from(await Bun.file(file).arrayBuffer())
|
||||
if (!GlobalFonts.register(font, family)) throw new Error(`Failed to register screenshot font: ${file}`)
|
||||
}
|
||||
|
||||
export function screenshot(renderer: CliRenderer) {
|
||||
return screenshotFrame({
|
||||
cols: renderer.currentRenderBuffer.width,
|
||||
rows: renderer.currentRenderBuffer.height,
|
||||
cursor: [0, 0],
|
||||
lines: renderer.currentRenderBuffer.getSpanLines(),
|
||||
})
|
||||
}
|
||||
|
||||
export function screenshotFrame(frame: CapturedFrame) {
|
||||
const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
|
||||
const context = canvas.getContext("2d")
|
||||
context.fillStyle = "#080808"
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
context.textBaseline = "top"
|
||||
|
||||
frame.lines.forEach((line, row) => {
|
||||
let column = 0
|
||||
line.spans.forEach((span) => {
|
||||
const attributes = span.attributes & 0xff
|
||||
const inverse = Boolean(attributes & TextAttributes.INVERSE)
|
||||
const hidden = Boolean(attributes & TextAttributes.HIDDEN)
|
||||
const foreground = inverse ? span.bg : span.fg
|
||||
const background = inverse ? span.fg : span.bg
|
||||
const chars = [...span.text]
|
||||
let remaining = span.width
|
||||
|
||||
chars.forEach((char, index) => {
|
||||
const cells = Math.max(1, remaining - (chars.length - index - 1))
|
||||
if (background.a) {
|
||||
context.fillStyle = color(background)
|
||||
context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight)
|
||||
}
|
||||
if (!hidden && char.codePointAt(0) !== 0x0a00) {
|
||||
context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1)
|
||||
const x = column * CellWidth
|
||||
const y = row * CellHeight
|
||||
if (!drawBlockElement(context, char, x, y, cells)) {
|
||||
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px ${FontStack}`
|
||||
context.fillText(char, x, y + 1)
|
||||
}
|
||||
if (attributes & TextAttributes.UNDERLINE) {
|
||||
context.fillRect(x, y + 17, cells * CellWidth, 1)
|
||||
}
|
||||
if (attributes & TextAttributes.STRIKETHROUGH) {
|
||||
context.fillRect(x, y + 10, cells * CellWidth, 1)
|
||||
}
|
||||
}
|
||||
column += cells
|
||||
remaining -= cells
|
||||
})
|
||||
while (remaining-- > 0) {
|
||||
if (background.a) {
|
||||
context.fillStyle = color(background)
|
||||
context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight)
|
||||
}
|
||||
column++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
data: canvas.toBuffer("image/png"),
|
||||
}
|
||||
}
|
||||
|
||||
function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: number, cells: number) {
|
||||
const width = cells * CellWidth
|
||||
if (char === "█") context.fillRect(x, y, width, CellHeight)
|
||||
else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2)
|
||||
else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2)
|
||||
else if (char === "┃") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight)
|
||||
else if (char === "╹") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight / 2)
|
||||
else return false
|
||||
return true
|
||||
}
|
||||
|
||||
function color(value: RGBA, opacity = 1) {
|
||||
const [red, green, blue, alpha] = value.toInts()
|
||||
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
|
||||
}
|
||||
|
||||
export * as SimulationPng from "./png"
|
||||
@@ -17,8 +17,6 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request,
|
||||
)
|
||||
case "ui.capture":
|
||||
return SimulationActions.capture(harness)
|
||||
case "ui.screenshot":
|
||||
return SimulationActions.screenshot(harness, request.params?.name)
|
||||
case "ui.state":
|
||||
return Effect.sync(() => SimulationActions.state(harness))
|
||||
case "ui.snapshot":
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Queue, Schema } from "effect"
|
||||
import { SimulationControlServer } from "../src/control-server"
|
||||
import { availableEndpoint, connect } from "./fixture/websocket"
|
||||
|
||||
const Request = Schema.Struct({ id: Schema.optional(Schema.Number) })
|
||||
|
||||
test("awaits accepted socket cleanup before the server scope closes", async () => {
|
||||
const endpoint = availableEndpoint()
|
||||
let cleaned = false
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* SimulationControlServer.start({
|
||||
endpoint,
|
||||
label: "control server test",
|
||||
data: () => ({}),
|
||||
decode: Schema.decodeUnknownEffect(Schema.fromJsonString(Request)),
|
||||
handle: () => Effect.succeed({ ok: true }),
|
||||
close: () =>
|
||||
Effect.promise(async () => {
|
||||
await Bun.sleep(25)
|
||||
cleaned = true
|
||||
}),
|
||||
})
|
||||
yield* connect(endpoint)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(cleaned).toBe(true)
|
||||
const url = new URL(endpoint)
|
||||
const rebound = Bun.serve({ hostname: url.hostname, port: Number(url.port), fetch: () => new Response() })
|
||||
await rebound.stop(true)
|
||||
})
|
||||
|
||||
test("continues serving after a response targets a closed socket", async () => {
|
||||
const endpoint = availableEndpoint()
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* SimulationControlServer.start({
|
||||
endpoint,
|
||||
label: "control server test",
|
||||
data: () => ({}),
|
||||
decode: Schema.decodeUnknownEffect(Schema.fromJsonString(Request)),
|
||||
handle: () => Effect.sleep(25).pipe(Effect.as({ ok: true })),
|
||||
})
|
||||
const closed = yield* connect(endpoint)
|
||||
closed.send(JSON.stringify({ id: 1 }))
|
||||
closed.close()
|
||||
yield* Effect.sleep(50)
|
||||
|
||||
const socket = yield* connect(endpoint)
|
||||
const messages = yield* Queue.unbounded<unknown>()
|
||||
socket.addEventListener("message", (event) => Queue.offerUnsafe(messages, JSON.parse(String(event.data))))
|
||||
socket.send(JSON.stringify({ id: 2 }))
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("disconnects and cleans up when an outbound message exceeds the queue bound", async () => {
|
||||
const endpoint = availableEndpoint()
|
||||
let cleaned = false
|
||||
let delivered = false
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* SimulationControlServer.start({
|
||||
endpoint,
|
||||
label: "control server test",
|
||||
data: () => ({}),
|
||||
decode: Schema.decodeUnknownEffect(Schema.fromJsonString(Request)),
|
||||
handle: (socket) =>
|
||||
Effect.gen(function* () {
|
||||
yield* socket.send("x".repeat(64 * 1024 * 1024 + 1))
|
||||
delivered = true
|
||||
return { ok: true }
|
||||
}),
|
||||
close: () => Effect.sync(() => void (cleaned = true)),
|
||||
})
|
||||
const socket = yield* connect(endpoint)
|
||||
const closed = yield* Queue.unbounded<void>()
|
||||
const messages: string[] = []
|
||||
socket.addEventListener("close", () => Queue.offerUnsafe(closed, undefined))
|
||||
socket.addEventListener("message", (event) => messages.push(String(event.data)))
|
||||
socket.send(JSON.stringify({ id: 1 }))
|
||||
yield* Queue.take(closed).pipe(Effect.timeout("5 seconds"))
|
||||
|
||||
expect(cleaned).toBe(true)
|
||||
expect(delivered).toBe(false)
|
||||
expect(messages).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas"
|
||||
import { RGBA, TextAttributes, type CapturedFrame } from "@opentui/core"
|
||||
import { SimulationPng } from "../src/frontend/png"
|
||||
|
||||
test("renders captured frames with bundled fonts", () => {
|
||||
const frame: CapturedFrame = {
|
||||
cols: 4,
|
||||
rows: 1,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text: "Test",
|
||||
width: 4,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: TextAttributes.BOLD | TextAttributes.ITALIC,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const image = SimulationPng.screenshotFrame(frame)
|
||||
expect(image.width).toBe(40)
|
||||
expect(image.height).toBe(20)
|
||||
expect(image.data.subarray(1, 4).toString()).toBe("PNG")
|
||||
})
|
||||
|
||||
test("renders OpenCode symbols instead of missing-glyph boxes", async () => {
|
||||
const pixels = async (text: string) => {
|
||||
const image = SimulationPng.screenshotFrame({
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text,
|
||||
width: 1,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(await loadImage(image.data), 0, 0)
|
||||
return [...context.getImageData(0, 0, image.width, image.height).data]
|
||||
}
|
||||
|
||||
const missingGlyph = await pixels("\u{10ffff}")
|
||||
for (const symbol of ["△", "✱", "⇆", "⠹"]) {
|
||||
expect(await pixels(symbol)).not.toEqual(missingGlyph)
|
||||
}
|
||||
})
|
||||
|
||||
test("fills adjacent block elements without glyph gaps", async () => {
|
||||
const image = SimulationPng.screenshotFrame({
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text: "▀▀",
|
||||
width: 2,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(await loadImage(image.data), 0, 0)
|
||||
|
||||
expect([...context.getImageData(0, 5, image.width, 1).data]).toEqual(
|
||||
Array.from({ length: image.width }, () => [255, 255, 255, 255]).flat(),
|
||||
)
|
||||
expect([...context.getImageData(0, 15, image.width, 1).data]).toEqual(
|
||||
Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(),
|
||||
)
|
||||
})
|
||||
|
||||
test("draws heavy vertical box elements on cell boundaries", async () => {
|
||||
const image = SimulationPng.screenshotFrame({
|
||||
cols: 1,
|
||||
rows: 2,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text: "┃",
|
||||
width: 1,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text: "╹",
|
||||
width: 1,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(await loadImage(image.data), 0, 0)
|
||||
|
||||
expect([...context.getImageData(4, 0, 2, 30).data]).toEqual(
|
||||
Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(),
|
||||
)
|
||||
expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat())
|
||||
})
|
||||
Reference in New Issue
Block a user