Compare commits

...
43 changed files with 5902 additions and 25 deletions

No files matched your search

+22
View File
@@ -290,6 +290,7 @@
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -598,6 +599,22 @@
"typescript": "catalog:",
},
},
"packages/plugin-genui": {
"name": "@opencode/plugin-genui",
"version": "2.0.27",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode/posts",
"dependencies": {
@@ -702,6 +719,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"@pierre/diffs": "catalog:",
@@ -2063,6 +2081,8 @@
"@opencode/plugin-browser": ["@opencode/plugin-browser@workspace:packages/plugin-browser"],
"@opencode/plugin-genui": ["@opencode/plugin-genui@workspace:packages/plugin-genui"],
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
@@ -5739,6 +5759,8 @@
"@opencode/files/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/gui-extensions/ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="],
"@opencode/gui-extensions/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"@opencode/posts/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
+2
View File
@@ -530,6 +530,8 @@ async function sendPrompt(
attachments: request.attachments,
agent: value.selection.agent,
model: selectionModel(value.selection),
// Tells the server the user reads replies in the app, so it can offer UI only the app renders.
client: "app",
},
}
@@ -90,6 +90,7 @@ export function SessionUIProvider(
shellOutput={(input) => serverSDK.api.shell.output(input)}
onNavigateToSession={navigateToSession}
onSessionHref={href}
onPrompt={(input) => data.session.prompt({ ...input, metadata: { client: "app" } }).then(() => undefined)}
>
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
<LocalProvider>{props.children}</LocalProvider>
+1
View File
@@ -111,6 +111,7 @@
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@parcel/watcher": "2.5.1",
+2
View File
@@ -88,6 +88,7 @@ import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import GenUIPlugin from "@opencode/plugin-genui"
import { CommandPlugin } from "./command.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
@@ -254,6 +255,7 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
BrowserPlugin,
GenUIPlugin,
ConfigCommandPlugin.Plugin,
ConfigCompactionPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
@@ -254,17 +254,28 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}),
true,
)
const toolInput = fragments("tool input", (id, value) =>
Effect.gen(function* () {
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID,
id,
text: value,
})
}),
const toolInput = fragments(
"tool input",
(id, value) =>
Effect.gen(function* () {
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID,
id,
text: value,
})
}),
(id, value) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID,
id,
delta: value,
})
}),
)
// Deltas are batched, but block starts are not. Publishing held deltas first keeps each
@@ -272,6 +283,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const publishPendingDeltas = Effect.fnUntraced(function* () {
yield* text.publishPending()
yield* reasoning.publishPending()
yield* toolInput.publishPending()
})
const flushFragments = Effect.fnUntraced(function* () {
@@ -506,26 +506,55 @@ test("authoritative end values replace accumulated deltas in the durable ended e
})
})
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
it.effect("batches tool input deltas and flushes pending input before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
const deltas = () =>
published.filter((event) => event.type === "session.tool.input.delta").map((event) => event.data)
yield* Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file.txt"}' }),
LLMEvent.toolInputEnd({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file' }),
],
publisher.publish,
{ discard: true },
),
)
)
expect(published.some((event) => event.type === "session.tool.input.delta")).toBe(false)
expect(published.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
text: '{"path":"file.txt"}',
})
})
expect(deltas()).toHaveLength(0)
yield* TestClock.adjust("100 millis")
expect(deltas()).toMatchObject([{ id: "call", delta: '{"path":"file' }])
yield* publisher.publish(LLMEvent.toolInputDelta({ id: "call", name: "read", text: '.txt"}' }))
yield* publisher.publish(LLMEvent.toolInputEnd({ id: "call", name: "read" }))
expect(published.slice(-2)).toMatchObject([
{ type: "session.tool.input.delta", data: { delta: '.txt"}' } },
{ type: "session.tool.input.ended.1", data: { text: '{"path":"file.txt"}' } },
])
}),
)
it.effect("publishes held tool input before the next block starts", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.textStart({ id: "text" }),
],
publisher.publish,
{ discard: true },
)
expect(published.map((event) => event.type).filter((type) => type !== "session.step.started.1")).toEqual([
"session.tool.input.started.1",
"session.tool.input.delta",
"session.text.started.1",
])
yield* publisher.flush()
}),
)
test("provider-executed tool metadata is flattened using the route key", async () => {
const { published, publisher } = capture("openai")
+54
View File
@@ -0,0 +1,54 @@
# GenUI plugin
`@opencode/plugin-genui` lets agents show interactive UI in the OpenCode app. The
agent writes a GenUI program in a `genui_render` call; the timeline draws it with
the app's own components while the call streams, and every call is checked so
the agent knows whether its UI rendered.
```
root = Surface([counts, actions])
$approved = 0
counts = Metrics([Metric("Approved", $approved + " of 4")])
actions = Actions([approve, apply])
approve = Button("Approve next", set($approved, $approved + 1), "primary")
apply = Button("Apply", reply("Apply the " + $approved + " approved changes"))
```
## Language
One statement per line, `name = Expression`, starting with `root = Surface(...)`.
Components take positional arguments; values are text, numbers, booleans,
`null`, lists and objects. `$name = value` declares state that lives in the
app; expressions (`+ - * / %`, comparisons, `&& || !`, `? :`) read it, and
`set`, `reset`, `reply` and `open` change it or answer the agent when a button
is pressed. There are no loops, functions or host access.
The language is ours and small on purpose: it parses while streaming, checks
statically, and has no install scripts, telemetry or third-party prompt text.
## Modules
- `catalog.ts`: every component as the Effect schema of its rendered node. The
props struct's key order is the positional order, its types check programs,
and the guide prints signatures from it.
- `lang.ts` and `lang/`: lexer, parser (incremental while streaming), evaluator,
static checks and the type printer. Generic over a catalog.
- `guide.ts`: the `genui` skill, generated from the catalog and examples.
- `tools.ts`: the skill and the `genui_render` tool, which returns each problem
by statement.
- `session.ts`: offers the tool, and adds a short reminder, only while the
session's latest prompt came from the app (`metadata.client = "app"`).
`./genui`, `./lang` and `./catalog` are browser-safe; `session-ui` renders with
them. `.` is the server plugin.
## Tests
Run `bun test` and `bun typecheck` from this package. Every guide example must
check clean and render from every prefix as it streams.
Disable through normal configuration:
```jsonc
{ "plugins": ["-opencode.genui"] }
```
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-genui",
"version": "2.0.27",
"description": "OpenCode's generative UI plugin",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/plugin-genui"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts",
"./genui": "./src/genui.ts",
"./lang": "./src/lang.ts",
"./catalog": "./src/catalog.ts"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit -p tsconfig.test.json",
"test": "bun test"
},
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bun
import { Script } from "@opencode/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import pkg from "../package.json"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
await $`bun run typecheck`
await $`bun run build`
const original = await Bun.file("package.json").text()
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
try {
await Bun.write(
"package.json",
JSON.stringify(
{
...pkg,
exports: Object.fromEntries(
Object.entries(pkg.exports).map(([name, value]) => [
name,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]),
),
},
null,
2,
) + "\n",
)
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", original)
await rm(tarball, { force: true })
}
+450
View File
@@ -0,0 +1,450 @@
export * as GenUICatalog from "./catalog.js"
import { Schema } from "effect"
import { GenUILang } from "./lang.js"
// Each component is the schema of its rendered node. The props struct is the whole contract: its key order is the
// positional argument order, its types check what the model wrote, and the guide prints its signature from it. Append
// new props at the end so existing programs keep their meaning.
const component = <const Name extends string, const Props extends Schema.Struct.Fields>(
name: Name,
description: string,
props: Props,
) =>
Schema.Struct({
typeName: Schema.Literal(name),
props: Schema.Struct(props),
statementId: Schema.optional(Schema.String),
}).annotate({ identifier: name, description })
/** An optional prop. `null` skips it positionally, e.g. `Metric("Spend", "$1,284", null, 12.4)`. */
const maybe = <S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
export const tones = ["neutral", "success", "warning", "danger", "info"] as const
export const stepStates = ["done", "current", "pending", "failed"] as const
export const buttonVariants = ["primary", "secondary", "ghost"] as const
export const formats = ["compact", "number", "currency", "percent", "duration", "bytes"] as const
/** What pressing a button or row does: `set`, `reset`, `reply` or `open`, evaluated when the user presses it. */
export const Action = Schema.declare((input: unknown): input is GenUILang.Action => input instanceof GenUILang.Action, {
identifier: "Action",
})
const behaviour = maybe(Schema.Union([Schema.String, Action, Schema.Array(Action)]))
export const Tone = Schema.Literals(tones).annotate({ identifier: "Tone" })
export const Format = Schema.Literals(formats).annotate({ identifier: "Format" })
const format = maybe(Format)
const scalar = Schema.Union([Schema.String, Schema.Number])
const numbers = Schema.Array(Schema.NullOr(Schema.Number))
const items = Schema.Array(Schema.Struct({ label: Schema.String, value: Schema.Number }))
export const Text = component(
"Text",
"Plain paragraph. Use Markdown instead for lists, links, emphasis or inline code.",
{
text: scalar,
muted: maybe(Schema.Boolean),
},
)
export const Markdown = component(
"Markdown",
"GitHub-flavored Markdown block for prose with lists, links, emphasis and inline code.",
{ text: Schema.String },
)
export const Code = component("Code", "Syntax-highlighted code block.", {
code: Schema.String,
language: maybe(Schema.String),
})
export const Divider = component("Divider", "Thin horizontal rule between unrelated blocks.", {})
export const Notice = component(
"Notice",
"Callout for one important fact: a warning, a failure, or a success the user must not miss. At most one per surface.",
{ title: Schema.String, description: maybe(Schema.String), tone: maybe(Tone) },
)
export const Status = component(
"Status",
"One line with a colored dot: a check, service or job state. detail is right-aligned muted text.",
{ label: Schema.String, tone: Tone, detail: maybe(Schema.String) },
)
export const Metric = component(
"Metric",
"One headline number inside Metrics. value is preformatted text; detail is a short muted note. delta is the percent change, colored green when up (red when inverse is true, for costs, latency or errors). trend is a short series drawn as a sparkline.",
{
label: Schema.String,
value: scalar,
detail: maybe(Schema.String),
delta: maybe(Schema.Number),
trend: maybe(Schema.Array(Schema.Number)),
inverse: maybe(Schema.Boolean),
},
)
export const Metrics = component("Metrics", "Grid of 2 to 4 Metric cells for headline numbers.", {
items: Schema.Array(Metric),
})
export const Progress = component(
"Progress",
"Labeled progress bar. value is a percentage from 0 to 100; detail replaces the percentage text.",
{ label: Schema.String, value: Schema.Number, detail: maybe(Schema.String) },
)
export const BarList = component(
"BarList",
"Ranked horizontal bars for comparing magnitudes, largest first. display is the formatted value text.",
{ items: Schema.Array(Schema.Struct({ label: Schema.String, value: Schema.Number, display: maybe(Schema.String) })) },
)
export const Table = component(
"Table",
"Read-only table. rows is an array of rows; each row has one plain string or number per column.",
{ columns: Schema.Array(Schema.String), rows: Schema.Array(Schema.Array(scalar)) },
)
export const Steps = component("Steps", "Ordered checklist for a plan or pipeline.", {
items: Schema.Array(
Schema.Struct({ title: Schema.String, detail: maybe(Schema.String), state: maybe(Schema.Literals(stepStates)) }),
),
})
export const Row = component(
"Row",
"One row of a Group: a label with an optional right-aligned value. A row with an action is clickable, like a Button.",
{ label: Schema.String, value: maybe(Schema.String), action: behaviour },
)
export const Group = component("Group", "Card of Rows for key-value facts or a list of choices.", {
rows: Schema.Array(Row),
})
export const Button = component(
"Button",
"action is text or actions. Text (default: label) is sent to you as the user's reply; inside a Form the form values go with it. Actions run in place, e.g. set($count, $count + 1). Use primary for the single main action.",
{ label: Schema.String, action: behaviour, variant: maybe(Schema.Literals(buttonVariants)) },
)
export const Link = component(
"Link",
"Button that opens an http(s) URL from the conversation or a tool result. Never invent URLs.",
{ label: Schema.String, url: Schema.String },
)
export const Actions = component("Actions", "Row of buttons.", { items: Schema.Array(Schema.Union([Button, Link])) })
const field = { name: Schema.String, label: Schema.String }
export const Input = component("Input", "Single-line text field. name is the key in the submitted values.", {
...field,
placeholder: maybe(Schema.String),
value: maybe(Schema.String),
hint: maybe(Schema.String),
})
export const TextArea = component("TextArea", "Multi-line text field.", {
...field,
placeholder: maybe(Schema.String),
value: maybe(Schema.String),
hint: maybe(Schema.String),
})
export const Select = component("Select", "Dropdown for one of many options (more than 4).", {
...field,
options: Schema.Array(Schema.String),
value: maybe(Schema.String),
hint: maybe(Schema.String),
})
export const Choice = component("Choice", "Radio list for one of 2 to 4 options that need to be read side by side.", {
...field,
options: Schema.Array(Schema.String),
value: maybe(Schema.String),
hint: maybe(Schema.String),
})
export const Segmented = component("Segmented", "Segmented control for one of 2 or 3 short options.", {
...field,
options: Schema.Array(Schema.String),
value: maybe(Schema.String),
})
export const Toggle = component("Toggle", "On/off switch.", {
...field,
value: maybe(Schema.Boolean),
hint: maybe(Schema.String),
})
export const Field = Schema.Union([Input, TextArea, Select, Choice, Segmented, Toggle]).annotate({
identifier: "Field",
})
export type Field = typeof Field.Type
export const Form = component(
"Form",
"Fields with their submit actions. Exactly one primary Button submits; every field name is unique.",
{ fields: Schema.Array(Field), actions: Actions },
)
export const Series = component(
"Series",
"One named data series for a chart, one value per label. null leaves a gap.",
{
name: Schema.String,
values: numbers,
},
)
export const BarChart = component(
"BarChart",
"Vertical bars over categories or time, e.g. daily cost or requests per day. One Series draws monochrome bars; several draw grouped bars, or stacked when stacked is true.",
{ labels: Schema.Array(Schema.String), series: Schema.Array(Series), stacked: maybe(Schema.Boolean), format },
)
export const LineChart = component("LineChart", "Smooth trend lines over time. area shades under a single line.", {
labels: Schema.Array(Schema.String),
series: Schema.Array(Series),
format,
area: maybe(Schema.Boolean),
})
export const AreaChart = component(
"AreaChart",
"Filled trend areas over time; stacked shows how parts add up to a total.",
{ labels: Schema.Array(Schema.String), series: Schema.Array(Series), format, stacked: maybe(Schema.Boolean) },
)
export const ComboChart = component(
"ComboChart",
"Bars and a line on two scales over the same labels, e.g. tokens as bars and users as a line.",
{ labels: Schema.Array(Schema.String), bars: Series, line: Series, barFormat: format, lineFormat: format },
)
export const ProjectionChart = component(
"ProjectionChart",
"Actual values so far plus a projection to the end of the range, with an optional target or limit line. actual has null after the last known point; projected has null before it.",
{
labels: Schema.Array(Schema.String),
actual: numbers,
projected: numbers,
target: maybe(Schema.Number),
format,
targetLabel: maybe(Schema.String),
},
)
export const DonutChart = component(
"DonutChart",
"Parts of a whole, 2 to 8 items, with a legend. center is the headline figure shown above the ring, e.g. the formatted total.",
{ items, center: maybe(Schema.String), format },
)
export const ShareBar = component(
"ShareBar",
"One horizontal bar split into parts of a whole, with a legend. Compact alternative to DonutChart.",
{ items, format },
)
export const Meter = component(
"Meter",
"Capacity or quota used, value 0 to 100. tone auto turns warning at 70 and danger above 95.",
{
label: Schema.String,
value: Schema.Number,
detail: maybe(Schema.String),
tone: maybe(Schema.Literals([...tones, "auto"])),
},
)
export const Gauge = component(
"Gauge",
"Where one value sits on a 0 to 100 scale, e.g. a ratio, score or percentile.",
{
label: Schema.String,
value: Schema.Number,
detail: maybe(Schema.String),
},
)
export const Pips = component("Pips", "Twenty-step rating meter for a score from 0 to 100, compact enough for lists.", {
label: Schema.String,
value: Schema.Number,
detail: maybe(Schema.String),
tone: maybe(Tone),
})
export const StatusStrip = component(
"StatusStrip",
"Timeline of discrete states, oldest first, e.g. CI runs, uptime checks or deploys. Each item has a label for its tooltip and a tone.",
{
items: Schema.Array(Schema.Struct({ label: Schema.String, tone: Tone })),
start: maybe(Schema.String),
end: maybe(Schema.String),
},
)
export const Heatmap = component(
"Heatmap",
"Intensity grid, e.g. activity by weekday and hour or errors by service and day. values has one row per row label and one value per column.",
{ rows: Schema.Array(Schema.String), columns: Schema.Array(Schema.String), values: Schema.Array(numbers), format },
)
export const RadarChart = component("RadarChart", "Compare up to 4 series across 3 to 8 axes, each scored 0 to 100.", {
axes: Schema.Array(Schema.String),
series: Schema.Array(Series),
})
export const ScatterChart = component(
"ScatterChart",
"Points on two numeric axes to show correlation or trade-offs, e.g. cost against quality.",
{
points: Schema.Array(Schema.Struct({ label: Schema.String, x: Schema.Number, y: Schema.Number })),
xLabel: Schema.String,
yLabel: Schema.String,
xFormat: format,
yFormat: format,
},
)
export const ComparisonRow = component(
"ComparisonRow",
"One row of a Comparison. best marks the winning cell: higher or lower numbers are better.",
{ label: Schema.String, values: Schema.Array(scalar), best: maybe(Schema.Literals(["higher", "lower"])) },
)
export const Comparison = component(
"Comparison",
"Side-by-side comparison of options, one column per option, with the best cell in each row highlighted.",
{ columns: Schema.Array(Schema.String), rows: Schema.Array(ComparisonRow) },
)
export const Block = Schema.Union([
Text,
Markdown,
Code,
Divider,
Notice,
Status,
Metrics,
Progress,
BarList,
Table,
Steps,
Group,
Actions,
Form,
BarChart,
LineChart,
AreaChart,
ComboChart,
ProjectionChart,
DonutChart,
ShareBar,
Meter,
Gauge,
Pips,
StatusStrip,
Heatmap,
RadarChart,
ScatterChart,
Comparison,
]).annotate({ identifier: "Block" })
export type Block = typeof Block.Type
export const Columns = component(
"Columns",
"Two or three blocks side by side, e.g. a DonutChart next to Metrics. Stacks when the panel is narrow.",
{ children: Schema.Array(Block) },
)
export const Section = component("Section", "Titled group of blocks. Sections do not nest.", {
title: Schema.String,
children: Schema.Array(Schema.Union([Block, Columns])),
description: maybe(Schema.String),
})
export const Surface = component("Surface", "The root. Blocks and Sections stack vertically.", {
children: Schema.Array(Schema.Union([Block, Columns, Section])),
})
export interface Surface extends Schema.Schema.Type<typeof Surface> {}
/** Components in the order the guide teaches them, with the advice that applies to each group. */
export const groups = [
{ name: "Layout", components: [Surface, Section, Columns], notes: [] },
{ name: "Content", components: [Text, Markdown, Code, Divider, Notice, Status], notes: [] },
{ name: "Data", components: [Metrics, Metric, Progress, BarList, Table, Steps, Group, Row], notes: [] },
{
name: "Charts",
components: [
Series,
BarChart,
LineChart,
AreaChart,
ComboChart,
ProjectionChart,
DonutChart,
ShareBar,
RadarChart,
ScatterChart,
Heatmap,
],
notes: [
"Prefer a chart over a table whenever numbers form a trend, a ranking, a share or a distribution.",
"Pass raw numbers and pick a format: compact (1.2k, 3.4M), number, currency (USD), percent (values already 0 to 100), duration (milliseconds) or bytes.",
"Keep labels short (e.g. Mon, 3/14, 14:00); about 7 to 31 labels read best.",
],
},
{ name: "Meters", components: [Meter, Gauge, Pips, StatusStrip, Comparison, ComparisonRow], notes: [] },
{ name: "Actions", components: [Actions, Button, Link], notes: [] },
{
name: "Forms",
components: [Form, Input, TextArea, Select, Choice, Segmented, Toggle],
notes: ["Field values reach you only when the user presses a Button inside the Form."],
},
] as const
export const catalog = GenUILang.catalog(
groups.flatMap((group): readonly Schema.Decoder<unknown>[] => group.components),
)
/** Checks a finished program against this catalog. */
export const check = (code: string) => GenUILang.check(GenUILang.parse(code), catalog, Surface)
/** Programs the guide shows the model. Each checks clean. */
export const examples = [
{
title: "Test run summary",
code: `root = Surface([summary, checks, coverage, next])
summary = Metrics([tests, duration, cov])
tests = Metric("Tests", "412", "3 failed")
duration = Metric("Duration", "48s", "6s faster than main")
cov = Metric("Coverage", "81.4%", "+0.6%")
checks = Section("Checks", [typecheck, unit, lint])
typecheck = Status("Typecheck", "success", "12s")
unit = Status("Unit tests", "danger", "3 failed")
lint = Status("Lint", "success", "4s")
coverage = Section("Coverage by package", [bars])
bars = BarList([{label: "core", value: 88, display: "88%"}, {label: "server", value: 79, display: "79%"}, {label: "app", value: 64, display: "64%"}])
next = Actions([fix, failures])
fix = Button("Fix failing tests", "Fix the 3 failing unit tests", "primary")
failures = Button("Show failures", "Show me the 3 failing tests with their errors", "secondary")`,
},
{
title: "Form that collects choices before acting",
code: `root = Surface([intro, form])
intro = Text("Pick the options for the new package. I will scaffold it after you submit.", true)
form = Form([name, kind, runtime, tests], actions)
name = Input("name", "Package name", "e.g. plugin-genui")
kind = Segmented("kind", "Kind", ["Library", "CLI", "Service"], "Library")
runtime = Select("runtime", "Runtime", ["Bun", "Node 22", "Deno", "Cloudflare Workers", "Browser"], "Bun")
tests = Toggle("tests", "Add a test suite", true)
actions = Actions([create, cancel])
create = Button("Create package", "Create the package with these options", "primary")
cancel = Button("Cancel", "Do not create the package", "ghost")`,
},
{
title: "Plan with a decision",
code: `root = Surface([plan, decide])
plan = Section("Migration plan", [steps])
steps = Steps([{title: "Add the new column", state: "done"}, {title: "Backfill rows", detail: "1.2M rows, about 4 minutes", state: "current"}, {title: "Switch reads"}, {title: "Drop the old column"}])
decide = Section("Backfill strategy", [options], "Batch size trades speed for lock time.")
options = Group([fast, safe])
fast = Row("Fast", "10k rows per batch", "Backfill in batches of 10k rows")
safe = Row("Safe", "1k rows per batch", "Backfill in batches of 1k rows")`,
},
{
title: "Data dashboard: lead with numbers, then charts",
code: `root = Surface([kpis, trend, split, cache, activity])
kpis = Metrics([spend, requests, latency])
spend = Metric("Spend", "$1,284", "this week", 12.4, [142, 158, 171, 166, 190, 204, 253], true)
requests = Metric("Requests", "48.2k", "this week", 8.1, [5.9, 6.4, 6.8, 6.6, 7.1, 7.4, 8.0])
latency = Metric("p95 latency", "820 ms", "this week", -6.3, [910, 880, 870, 860, 850, 835, 820], true)
trend = Section("Daily cost by model", [costs])
costs = BarChart(days, [opus, sonnet], true, "currency")
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
opus = Series("Opus", [92, 101, 110, 104, 121, 130, 168])
sonnet = Series("Sonnet", [50, 57, 61, 62, 69, 74, 85])
split = Columns([share, quota])
share = DonutChart([{label: "Opus", value: 826}, {label: "Sonnet", value: 458}], "$1,284", "currency")
quota = Meter("Monthly budget", 64, "$3,200 of $5,000", "auto")
cache = Gauge("Cache hit rate", 71, "71% of input tokens")
activity = Section("Requests by hour", [heat])
heat = Heatmap(["Mon", "Tue", "Wed"], ["9", "12", "15", "18"], [[120, 340, 410, 90], [140, 380, 450, 120], [100, 300, 390, 80]], "compact")`,
},
{
title: "Interactive review: state that updates in place, then one reply",
code: `root = Surface([counts, status, actions])
$approved = 0
$total = 4
counts = Metrics([done, left])
done = Metric("Approved", $approved + " of " + $total)
left = Metric("Remaining", $total - $approved)
status = $approved == $total ? ready : review
ready = Notice("All changes approved", "Apply them when you are ready.", "success")
review = Progress("Review", $approved / $total * 100)
actions = Actions([approve, undo, apply])
approve = Button("Approve next", set($approved, $approved < $total ? $approved + 1 : $total), "primary")
undo = Button("Undo", set($approved, $approved > 0 ? $approved - 1 : 0), "ghost")
apply = Button("Apply approved", reply("Apply the " + $approved + " approved changes"), "secondary")`,
},
] as const
+33
View File
@@ -0,0 +1,33 @@
export * as GenUI from "./genui.js"
import { Schema } from "effect"
export const MAX_CODE = 100_000
/** The model-facing name of the render tool, and the tool name its calls carry in the timeline. */
export const RENDER_TOOL = "genui_render"
export const SurfaceID = Schema.String.check(Schema.isPattern(/^ui_[A-Za-z0-9_-]{1,96}$/u))
.pipe(Schema.brand("GenUI.SurfaceID"))
.annotate({ identifier: "GenUI.SurfaceID" })
export type SurfaceID = typeof SurfaceID.Type
/**
* The surface a render call creates when it names none: derived from the call ID, so a client can identify a
* surface from the call while it still streams.
*/
export const surfaceFor = (callID: string) => SurfaceID.make(`ui_${callID.replace(/[^A-Za-z0-9_-]/gu, "").slice(-96)}`)
/** A render call's arguments as clients see them: any field may still be missing or cut off while it streams. */
export const RenderInput = Schema.Struct({
title: Schema.optional(Schema.String),
surfaceID: Schema.optional(Schema.String),
code: Schema.optional(Schema.String),
})
export type RenderInput = typeof RenderInput.Type
/** Strips the Markdown fence models sometimes wrap a program in, including one that is still open. */
export const unfence = (code: string) =>
code
.trim()
.replace(/^```[\w-]*\n?/u, "")
.replace(/\n?```$/u, "")
+63
View File
@@ -0,0 +1,63 @@
export * as GenUIGuide from "./guide.js"
import { GenUICatalog } from "./catalog.js"
import { GenUI } from "./genui.js"
import { GenUILang } from "./lang.js"
const name = (element: { readonly fields: { readonly typeName: { readonly literal: string } } }) =>
element.fields.typeName.literal
const describe = (element: Parameters<typeof name>[0]) => {
const component = GenUICatalog.catalog.components.get(name(element))
return component ? `- \`${GenUILang.signature(component)}\`: ${component.description}` : ""
}
/** The genui skill: everything the model needs to write a valid program, generated from the catalog. */
export const text = [
"# GenUI",
"",
`${GenUI.RENDER_TOOL} shows the user interactive UI in the OpenCode app, drawn with the app's own components while you write it. The \`code\` argument is a GenUI program and nothing else: no Markdown fences, no prose. The app owns every color, size and spacing; you choose structure and content.`,
"",
"## Syntax",
"",
"- One statement per line: `name = Expression`. Write `root = Surface([...])` first, then each block in reading order, so the surface fills in from the top while you write.",
"- A statement may use a name defined further down; the block appears once both exist.",
'- Values: text in double or single quotes on one line ("a\\nb" for a line break), numbers, true, false, null, lists `[a, b]` and objects `{key: value}`.',
"- Components take positional arguments in the order of their signature; `?` marks an optional one. Pass null to skip an optional argument and set a later one.",
"- Give each block its own named statement. A named block streams in once and stays put when you update the surface.",
"",
"## Components",
"",
...GenUICatalog.groups.flatMap((group) => [
`### ${group.name}`,
"",
...group.components.map(describe),
...group.notes.map((note) => `- ${note}`),
"",
]),
`Block is any of: ${GenUICatalog.Block.members.map(name).join(", ")}.`,
`Field is any of: ${GenUICatalog.Field.members.map(name).join(", ")}.`,
`Tone is one of: ${GenUICatalog.tones.map((tone) => JSON.stringify(tone)).join(", ")}.`,
`Format is one of: ${GenUICatalog.formats.map((format) => JSON.stringify(format)).join(", ")}.`,
"Action is set, reset, reply or open; see State and actions.",
"",
"## State and actions",
"",
"- Declare state with `$name = initial value`, e.g. `$count = 0`. It lives in the user's app; you see it only in a reply.",
'- Expressions: `+ - * / %` on numbers, `+` also joins text (`"Total: " + $count`), `== != < > <= >=`, `&& || !`, and `test ? a : b`. A `? :` that picks between two components shows one block or the other.',
"- Actions: `set($name, value)`, `reset($name, ...)` (or `reset()` for every $name), `reply(message)` and `open(url)`. Give a Button or Row one action or a list of them; they run when the user presses it, against the state at that moment. `reply` sends its message to you as the user's next message.",
"- There are no loops or functions. Compute data before you render; use state only for what the user changes on the surface.",
"",
"## Rules",
"",
"- Show real data from the workspace, tool results and the conversation. Label illustrative or estimated values as such.",
"- Prefer a few dense blocks over many sparse ones. Title groups with Section; do not repeat the surface title inside it.",
"- Use tone for meaning only: success, warning and danger for outcomes, neutral or info otherwise.",
"- Use Notice only for a fact the user must not miss. Use Status for states and Text for everything else.",
"- Write every reply message as a complete instruction that reads well on its own in the chat.",
`- Every call is checked. The result lists each problem by statement, and the blocks it affects do not render. Fix them and call ${GenUI.RENDER_TOOL} again with the same surfaceID and the complete program.`,
"",
"## Examples",
"",
...GenUICatalog.examples.flatMap((example) => [`### ${example.title}`, "", "```", example.code, "```", ""]),
].join("\n")
+13
View File
@@ -0,0 +1,13 @@
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
import { GenUISession } from "./session.js"
import { GenUITools } from "./tools.js"
export default Plugin.define({
id: "opencode.genui",
effect: (ctx) =>
Effect.gen(function* () {
yield* GenUITools.register(ctx)
yield* GenUISession.register(ctx)
}),
})
+13
View File
@@ -0,0 +1,13 @@
export * as GenUILang from "./lang.js"
export type { BinaryOperator, Expr, Issue, Literal, Program, Statement } from "./lang/ast.js"
export { catalog, print, signature, type Catalog, type Component, type Field, type Slot } from "./lang/components.js"
export { Action, check, initial, perform, render, type Data, type Outcome, type State } from "./lang/evaluate.js"
export { parse, type Cache } from "./lang/parser.js"
import { parse, type Cache } from "./lang/parser.js"
/** A parser for one program as it streams in: each finished statement parses once, however often the program grows. */
export function parser() {
const cache: Cache = new Map()
return (source: string, partial: boolean) => parse(source, { partial, cache })
}
+33
View File
@@ -0,0 +1,33 @@
export type Literal = string | number | boolean | null
export type BinaryOperator = "||" | "&&" | "==" | "!=" | "<" | ">" | "<=" | ">=" | "+" | "-" | "*" | "/" | "%"
export type Expr =
| { readonly type: "literal"; readonly value: Literal }
| { readonly type: "list"; readonly items: readonly Expr[] }
| { readonly type: "object"; readonly entries: readonly (readonly [string, Expr])[] }
| { readonly type: "ref"; readonly name: string }
| { readonly type: "state"; readonly name: string }
| { readonly type: "call"; readonly name: string; readonly args: readonly Expr[] }
| { readonly type: "unary"; readonly operator: "!" | "-"; readonly operand: Expr }
| { readonly type: "binary"; readonly operator: BinaryOperator; readonly left: Expr; readonly right: Expr }
| { readonly type: "conditional"; readonly test: Expr; readonly then: Expr; readonly otherwise: Expr }
/** One `name = expression` line. State declarations keep their `$`, e.g. `$count`. */
export interface Statement {
readonly id: string
readonly line: number
readonly expr: Expr
}
/** A problem the author can fix, located by statement when it belongs to one. */
export interface Issue {
readonly statement?: string
readonly line?: number
readonly message: string
}
export interface Program {
readonly statements: readonly Statement[]
readonly issues: readonly Issue[]
}
@@ -0,0 +1,155 @@
import { Result, Schema, SchemaAST, SchemaIssue } from "effect"
/** The components a field holds, when it holds components rather than data. */
export interface Slot {
readonly names: readonly string[]
readonly list: boolean
}
export interface Field {
readonly name: string
readonly optional: boolean
/** The field's type as the guide and error messages print it, e.g. `string | number` or `Metric[]`. */
readonly type: string
readonly slot?: Slot
}
export interface Component {
readonly name: string
readonly description: string
/** Positional order: the nth argument fills the nth field. */
readonly fields: readonly Field[]
/** Decodes a `{ typeName, props, statementId? }` node, or lists what is wrong with it. */
readonly decode: (node: object) => Result.Result<unknown, readonly string[]>
}
export interface Catalog {
readonly components: ReadonlyMap<string, Component>
}
const format = SchemaIssue.makeFormatterStandardSchemaV1()
/**
* A catalog from component schemas: structs of `{ typeName, props, statementId? }` whose `typeName` is a literal and
* whose annotations carry a description. Positional order and slots come from the `props` struct.
*/
export function catalog(elements: readonly Schema.Decoder<unknown>[]): Catalog {
const names = new Set(elements.map((element) => nameOf(element.ast)))
return {
components: new Map(
elements.map((element) => {
const name = nameOf(element.ast)
const decode = Schema.decodeUnknownResult(element)
const component: Component = {
name,
description:
typeof element.ast.annotations?.description === "string" ? element.ast.annotations.description : "",
fields: propsOf(element.ast).map((property) => ({
name: String(property.name),
optional: property.type.context?.isOptional === true,
type: print(property.type, true),
...slotOf(property.type, names),
})),
decode: (node) =>
Result.mapError(decode(node), (error) =>
format(error.issue).issues.map((issue) => {
const keys = (issue.path ?? []).map((segment) => (typeof segment === "object" ? segment.key : segment))
const path = keys
.slice(1)
.map((key) => (typeof key === "number" ? `[${key}]` : `.${String(key)}`))
.join("")
const actual = keys.reduce<unknown>(
(value, key) =>
value !== null && typeof value === "object"
? Object.getOwnPropertyDescriptor(value, key)?.value
: undefined,
node,
)
const got =
actual === undefined ? "" : `, got ${JSON.stringify(actual)?.slice(0, 60) ?? String(actual)}`
return `${name}${path}: ${issue.message}${got}`
}),
),
}
return [name, component] as const
}),
),
}
}
/** `Name(field: type, optional?: type)`, the form the guide teaches. */
export function signature(component: Component) {
return `${component.name}(${component.fields.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")})`
}
/** Prints a schema as a short type. Named schemas print their identifier; `top` drops the null of an optional field. */
export function print(ast: SchemaAST.AST, top = false): string {
const identifier = ast.annotations?.identifier
if (typeof identifier === "string") return identifier
if (ast._tag === "Union")
return [
...new Set(
members(ast)
.filter((member) => member._tag !== "Undefined" && !(top && member._tag === "Null"))
.map((member) => print(member)),
),
].join(" | ")
if (ast._tag === "Arrays") {
const item = ast.rest[0]
if (!item) return "unknown[]"
return members(item).length > 1 ? `(${print(item)})[]` : `${print(item)}[]`
}
if (ast._tag === "Objects")
return `{${ast.propertySignatures
.map(
(property) =>
`${String(property.name)}${property.type.context?.isOptional ? "?" : ""}: ${print(property.type, true)}`,
)
.join(", ")}}`
if (ast._tag === "Literal") return JSON.stringify(ast.literal)
if (ast._tag === "String" || ast._tag === "Number" || ast._tag === "Boolean") return ast._tag.toLowerCase()
if (ast._tag === "Null") return "null"
return "unknown"
}
/** Union members, flattened through unions that have no name of their own. */
export function members(ast: SchemaAST.AST): SchemaAST.AST[] {
if (ast._tag !== "Union" || typeof ast.annotations?.identifier === "string") return [ast]
return ast.types.flatMap(members)
}
function present(ast: SchemaAST.AST) {
return members(ast).filter((member) => member._tag !== "Null" && member._tag !== "Undefined")
}
function slotOf(ast: SchemaAST.AST, names: ReadonlySet<string>): { readonly slot?: Slot } {
const types = present(ast)
const list = types.length === 1 && types[0]?._tag === "Arrays" ? types[0].rest[0] : undefined
const held = (list ? present(list) : types).flatMap(expand)
const found = held.flatMap((member) => (member._tag === "Objects" ? [nameOf(member, false)] : []))
if (held.length === 0 || found.length !== held.length || !found.every((name) => name && names.has(name))) return {}
return { slot: { names: found.filter((name) => name !== undefined), list: list !== undefined } }
}
/** Named unions such as `Block` hold their members. */
function expand(ast: SchemaAST.AST): SchemaAST.AST[] {
return ast._tag === "Union" ? ast.types.flatMap(expand) : [ast]
}
function propsOf(ast: SchemaAST.AST) {
const props =
ast._tag === "Objects" ? ast.propertySignatures.find((property) => property.name === "props") : undefined
if (props?.type._tag !== "Objects") throw new Error("A component schema needs a props struct")
return props.type.propertySignatures
}
function nameOf(ast: SchemaAST.AST, strict?: true): string
function nameOf(ast: SchemaAST.AST, strict: false): string | undefined
function nameOf(ast: SchemaAST.AST, strict = true) {
const type =
ast._tag === "Objects" ? ast.propertySignatures.find((property) => property.name === "typeName")?.type : undefined
if (type?._tag === "Literal" && typeof type.literal === "string") return type.literal
if (strict) throw new Error("A component schema needs a literal typeName")
return undefined
}
+460
View File
@@ -0,0 +1,460 @@
import { Option, Result, Schema } from "effect"
import type { BinaryOperator, Expr, Issue, Literal, Program, Statement } from "./ast.js"
import { signature, type Catalog, type Component, type Field } from "./components.js"
/** JSON-like values: what state holds and what a button can set. */
export type Data = Literal | readonly Data[] | { readonly [key: string]: Data }
export type State = ReadonlyMap<string, Data>
export type ActionName = "set" | "reset" | "reply" | "open"
/** What pressing a button does. Its arguments evaluate when the user presses it, against the state at that moment. */
export class Action {
constructor(
readonly name: ActionName,
readonly args: readonly Expr[],
) {}
}
/** A rendered component, already decoded. */
class Element {
constructor(
readonly typeName: string,
readonly node: unknown,
) {}
}
type Value = Data | readonly Value[] | { readonly [key: string]: Value } | Action | Element
/** The effect of one action, for the renderer to apply. */
export type Outcome =
| { readonly type: "set"; readonly name: string; readonly value: Data }
| { readonly type: "reset"; readonly names: readonly string[] }
| { readonly type: "reply"; readonly message: string }
| { readonly type: "open"; readonly url: string }
const actions: Readonly<Record<ActionName, { readonly arity: (count: number) => boolean; readonly usage: string }>> = {
set: { arity: (count) => count === 2, usage: "set($name, value)" },
reset: { arity: () => true, usage: "reset($name, ...), or reset() for every $name" },
reply: { arity: (count) => count === 1, usage: "reply(message)" },
open: { arity: (count) => count === 1, usage: "open(url)" },
}
const isAction = (name: string): name is ActionName => Object.hasOwn(actions, name)
export interface RenderOptions<S extends Schema.Decoder<unknown>> {
readonly catalog: Catalog
/** The schema the root must decode to. Its component is the only one allowed at the root. */
readonly root: S
readonly state: State
/**
* The last node each statement rendered. While a program streams, a statement that does not render yet, such as a
* chart whose last data point is half written, keeps showing its previous node instead of disappearing.
*/
readonly memory?: Map<string, unknown>
}
/** Renders a program against a state. Nodes that fail are left out and reported; everything else still renders. */
export function render<S extends Schema.Decoder<unknown>>(program: Program, options: RenderOptions<S>) {
const run = evaluator(program, options.catalog, options.state, options.memory)
const root = rootOf(program)
if (!root) return { root: null, issues: [{ message: "Nothing to render. Start with root = Surface([...])." }] }
const value = run.statement(root, undefined)
const decoded = value instanceof Element ? Schema.decodeUnknownOption(options.root)(value.node) : Option.none()
if (Option.isNone(decoded))
run.report(
program.statements.find((statement) => statement.id === root),
`${root} must be a ${nameOf(options.root)}(...)`,
)
return { root: Option.getOrNull(decoded), issues: run.issues }
}
/** The starting value of every `$name`. Each may use the ones declared before it. */
export function initial(program: Program, catalog: Catalog) {
const issues: Issue[] = []
const state = new Map<string, Data>()
program.statements
.filter((statement) => statement.id.startsWith("$"))
.forEach((statement) => {
const run = evaluator(program, catalog, state)
const value = run.evaluate(statement.expr, statement)
issues.push(...run.issues)
if (isData(value)) return state.set(statement.id, value)
issues.push(at(statement, `${statement.id} must start as text, a number, true, false, null, a list or an object`))
state.set(statement.id, null)
})
return { state, issues }
}
/** Evaluates an action against the current state. Returns nothing for an action that cannot run. */
export function perform(action: Action, program: Program, catalog: Catalog, state: State): Outcome | undefined {
const run = evaluator(program, catalog, state)
const target = action.args[0]
if (action.name === "set") {
const value = action.args[1] ? run.evaluate(action.args[1], undefined) : null
if (target?.type !== "state" || !state.has(target.name) || !isData(value)) return undefined
return { type: "set", name: target.name, value }
}
if (action.name === "reset")
return {
type: "reset",
names:
action.args.length === 0
? [...state.keys()]
: action.args.flatMap((arg) => (arg.type === "state" ? [arg.name] : [])),
}
const value = target ? run.evaluate(target, undefined) : null
if (action.name === "reply") return { type: "reply", message: text(value) }
return typeof value === "string" ? { type: "open", url: value } : undefined
}
/**
* Everything wrong with a finished program, for the author: syntax, unknown names, wrong arguments, values of the
* wrong type at the initial state, and statements nothing uses.
*/
export function check<S extends Schema.Decoder<unknown>>(program: Program, catalog: Catalog, root: S) {
const start = initial(program, catalog)
const rendered = render(program, { catalog, root, state: start.state })
const issues = [
...program.issues,
...duplicates(program),
...start.issues,
...rendered.issues,
...lint(program, catalog),
]
const seen = new Set<string>()
return {
root: rendered.root,
issues: issues.filter((issue) => {
const key = `${issue.statement ?? ""}\n${issue.message}`
if (seen.has(key)) return false
seen.add(key)
return true
}),
}
}
function evaluator(program: Program, catalog: Catalog, state: State, memory?: Map<string, unknown>) {
const statements = new Map(program.statements.map((statement) => [statement.id, statement]))
const values = new Map<string, Value>()
const stack: string[] = []
const issues: Issue[] = []
const report = (statement: Statement | undefined, message: string) => issues.push(at(statement, message))
const statement = (id: string, from: Statement | undefined): Value => {
const known = values.get(id)
if (known !== undefined) return known
const target = statements.get(id)
const remembered = memory?.get(id)
const kept = remembered instanceof Element ? remembered : undefined
if (!target) {
if (kept) return kept
report(from, `${id} is used but never defined`)
return null
}
if (stack.includes(id)) {
report(from, `${[...stack.slice(stack.indexOf(id)), id].join(" -> ")} refer to each other in a loop`)
return null
}
stack.push(id)
const value = evaluate(target.expr, target, id)
stack.pop()
const result = value === null && kept ? kept : value
if (result instanceof Element && target.expr.type === "call") memory?.set(id, result)
values.set(id, result)
return result
}
const evaluate = (expr: Expr, at: Statement | undefined, id?: string): Value => {
switch (expr.type) {
case "literal":
return expr.value
case "list":
return expr.items.map((item) => evaluate(item, at))
case "object":
return Object.fromEntries(expr.entries.map(([key, value]) => [key, evaluate(value, at)]))
case "ref":
return statement(expr.name, at)
case "state": {
const value = state.get(expr.name)
if (value !== undefined) return value
if (!state.has(expr.name)) report(at, undeclared(expr.name, state))
return null
}
case "unary": {
const operand = evaluate(expr.operand, at)
if (expr.operator === "!") return !truthy(operand)
if (typeof operand === "number") return -operand
report(at, `- needs a number, got ${kind(operand)}`)
return null
}
case "binary": {
const left = evaluate(expr.left, at)
if (expr.operator === "&&") return truthy(left) ? evaluate(expr.right, at) : left
if (expr.operator === "||") return truthy(left) ? left : evaluate(expr.right, at)
return binary(expr.operator, left, evaluate(expr.right, at), at)
}
case "conditional":
return truthy(evaluate(expr.test, at)) ? evaluate(expr.then, at) : evaluate(expr.otherwise, at)
case "call":
return call(expr.name, expr.args, at, id)
}
}
const binary = (operator: BinaryOperator, left: Value, right: Value, at: Statement | undefined): Value => {
if (operator === "==") return equal(left, right)
if (operator === "!=") return !equal(left, right)
if (operator === "+" && (typeof left === "string" || typeof right === "string"))
return `${text(left)}${text(right)}`
const ordering = operator === "<" || operator === ">" || operator === "<=" || operator === ">="
if (ordering && typeof left === "string" && typeof right === "string") return compare(operator, left, right)
if (typeof left !== "number" || typeof right !== "number") {
report(
at,
`${operator} needs two numbers${operator === "+" ? " or some text" : ""}, got ${kind(left)} and ${kind(right)}`,
)
return null
}
if ((operator === "/" || operator === "%") && right === 0) {
report(at, `${operator} by zero`)
return null
}
if (operator === "+") return left + right
if (operator === "-") return left - right
if (operator === "*") return left * right
if (operator === "/") return left / right
if (operator === "%") return left % right
return compare(operator, left, right)
}
const call = (name: string, args: readonly Expr[], at: Statement | undefined, id: string | undefined): Value => {
if (isAction(name)) return new Action(name, args)
const component = catalog.components.get(name)
if (!component) {
report(at, unknownComponent(name, catalog))
return null
}
return element(
component,
args.map((arg) => evaluate(arg, at)),
at,
id,
)
}
const element = (component: Component, args: readonly Value[], at: Statement | undefined, id: string | undefined) => {
if (args.length > component.fields.length)
report(at, `${signature(component)} takes ${component.fields.length} arguments, got ${args.length}`)
const props: Record<string, unknown> = {}
for (const [index, field] of component.fields.entries()) {
const arg = args[index]
const value =
arg === undefined || arg === null
? arg
: field.slot
? slot(component, field, arg, at)
: plain(component, field, arg, at)
if ((value === undefined || value === null) && !field.optional) {
if (value !== null || arg === null) report(at, `${component.name} needs ${field.name}: ${signature(component)}`)
return null
}
if (value !== undefined) props[field.name] = value
}
const decoded = component.decode({
typeName: component.name,
props,
...(id === undefined ? {} : { statementId: id }),
})
if (Result.isSuccess(decoded)) return new Element(component.name, decoded.success)
decoded.failure.forEach((message) => report(at, message))
return null
}
// A field that holds components takes rendered nodes. Others take data and actions, never a rendered node.
const slot = (component: Component, field: Field, arg: Value, at: Statement | undefined) => {
const allowed = field.slot?.names ?? []
const type = field.slot?.list ? field.type.replace(/^\((.*)\)\[\]$|\[\]$/u, "$1") : field.type
const accept = (item: Value) => {
if (item === null) return []
if (item instanceof Element && allowed.includes(item.typeName)) return [item.node]
const homes =
item instanceof Element
? [...catalog.components.values()].filter((other) =>
other.fields.some((candidate) => candidate.slot?.names.includes(item.typeName)),
)
: []
const hint = homes.length > 0 ? `; ${kind(item)} goes in ${homes.map((home) => home.name).join(" or ")}` : ""
report(at, `${component.name}.${field.name} takes ${type}, not ${kind(item)}${hint}`)
return []
}
if (!field.slot?.list) return accept(arg)[0] ?? null
if (!Array.isArray(arg)) {
report(at, `${component.name}.${field.name} takes a list of ${type}`)
return null
}
return arg.flatMap(accept)
}
const plain = (component: Component, field: Field, arg: Value, at: Statement | undefined) => {
if (!contains(arg, (item) => item instanceof Element)) return arg
report(at, `${component.name}.${field.name} takes ${field.type}, not a component`)
return null
}
return { statement, evaluate, issues, report }
}
/** Unknown names, wrong arity and orphans, across every branch, including the ones the initial state skips. */
function lint(program: Program, catalog: Catalog) {
const statements = new Map(program.statements.map((statement) => [statement.id, statement]))
const declared = new Set(
program.statements.filter((statement) => statement.id.startsWith("$")).map((statement) => statement.id),
)
const issues: Issue[] = []
const walk = (expr: Expr, statement: Statement, visit: (name: string) => void): void => {
if (expr.type === "ref") {
if (!statements.has(expr.name)) issues.push(at(statement, `${expr.name} is used but never defined`))
return visit(expr.name)
}
if (expr.type === "state") {
if (!declared.has(expr.name))
issues.push(at(statement, undeclared(expr.name, new Map([...declared].map((name) => [name, null])))))
return
}
if (expr.type === "call") {
const component = catalog.components.get(expr.name)
if (isAction(expr.name)) {
if (!actions[expr.name].arity(expr.args.length)) issues.push(at(statement, `Use ${actions[expr.name].usage}`))
if (
(expr.name === "set" && expr.args[0]?.type !== "state") ||
(expr.name === "reset" && expr.args.some((arg) => arg.type !== "state"))
)
issues.push(at(statement, `${expr.name} takes a $name: ${actions[expr.name].usage}`))
} else if (!component) issues.push(at(statement, unknownComponent(expr.name, catalog)))
return expr.args.forEach((arg) => walk(arg, statement, visit))
}
if (expr.type === "list") return expr.items.forEach((item) => walk(item, statement, visit))
if (expr.type === "object") return expr.entries.forEach(([, value]) => walk(value, statement, visit))
if (expr.type === "unary") return walk(expr.operand, statement, visit)
if (expr.type === "binary") return [expr.left, expr.right].forEach((side) => walk(side, statement, visit))
if (expr.type === "conditional")
return [expr.test, expr.then, expr.otherwise].forEach((part) => walk(part, statement, visit))
}
const reached = new Set<string>()
const reach = (id: string) => {
const statement = statements.get(id)
if (!statement || reached.has(id)) return
reached.add(id)
walk(statement.expr, statement, reach)
}
const root = rootOf(program)
if (root) reach(root)
program.statements.forEach((statement) => {
if (statement.id.startsWith("$")) return walk(statement.expr, statement, () => undefined)
if (!reached.has(statement.id)) {
walk(statement.expr, statement, () => undefined)
issues.push(at(statement, `${statement.id} is defined but nothing under root uses it, so it does not render`))
}
})
return issues
}
function duplicates(program: Program) {
const seen = new Set<string>()
return program.statements.flatMap((statement) => {
if (!seen.has(statement.id)) {
seen.add(statement.id)
return []
}
return [at(statement, `${statement.id} is defined more than once; the last one wins`)]
})
}
function rootOf(program: Program) {
if (program.statements.some((statement) => statement.id === "root")) return "root"
return program.statements.find((statement) => !statement.id.startsWith("$"))?.id
}
function at(statement: Statement | undefined, message: string): Issue {
return statement ? { statement: statement.id, line: statement.line, message } : { message }
}
function unknownComponent(name: string, catalog: Catalog) {
const names = [...catalog.components.keys()]
const close = names.find((candidate) => distance(candidate.toLowerCase(), name.toLowerCase()) <= 2)
if (close) return `${name} is not a component. Did you mean ${close}?`
if (/^[a-z]/u.test(name)) return `${name} is not a component or an action. Actions are set, reset, reply and open.`
return `${name} is not a component. Components: ${names.join(", ")}`
}
function undeclared(name: string, state: State) {
const names = [...state.keys()]
return `${name} is not declared. Declare it with ${name} = <initial value>${names.length ? ` (declared: ${names.join(", ")})` : ""}`
}
function nameOf(schema: Schema.Decoder<unknown>) {
return typeof schema.ast.annotations?.identifier === "string" ? schema.ast.annotations.identifier : "component"
}
function truthy(value: Value) {
return value !== null && value !== false && value !== 0 && value !== ""
}
function equal(left: Value, right: Value): boolean {
if (left === right) return true
return isData(left) && isData(right) && JSON.stringify(left) === JSON.stringify(right)
}
function compare(operator: BinaryOperator, left: number | string, right: number | string) {
if (operator === "<") return left < right
if (operator === ">") return left > right
if (operator === "<=") return left <= right
return left >= right
}
/** How a value reads when joined into text. Numbers drop floating-point noise. */
function text(value: Value): string {
if (typeof value === "string") return value
if (typeof value === "number") return String(Math.round(value * 1e6) / 1e6)
if (typeof value === "boolean") return String(value)
if (Array.isArray(value)) return value.map(text).join(", ")
return ""
}
function kind(value: Value): string {
if (value instanceof Element) return value.typeName
if (value instanceof Action) return `${value.name}(...)`
if (value === null) return "null"
if (Array.isArray(value)) return "a list"
if (typeof value === "object") return "an object"
return typeof value === "string" ? `text ${JSON.stringify(value)}` : `${typeof value} ${String(value)}`
}
function isData(value: Value): value is Data {
return !contains(value, (item) => item instanceof Element || item instanceof Action)
}
function contains(value: Value, test: (value: Value) => boolean): boolean {
if (test(value)) return true
if (Array.isArray(value)) return value.some((item) => contains(item, test))
if (value !== null && typeof value === "object" && !(value instanceof Element) && !(value instanceof Action))
return Object.values(value).some((item) => contains(item, test))
return false
}
function distance(left: string, right: string) {
const row = Array.from({ length: right.length + 1 }, (_, index) => index)
for (const [i, a] of [...left].entries()) {
let previous = row[0] ?? 0
row[0] = i + 1
for (const [j, b] of [...right].entries()) {
const current = row[j + 1] ?? 0
row[j + 1] = Math.min(current + 1, (row[j] ?? 0) + 1, previous + (a === b ? 0 : 1))
previous = current
}
}
return row[right.length] ?? 0
}
+132
View File
@@ -0,0 +1,132 @@
const marks = [
"==",
"!=",
"<=",
">=",
"&&",
"||",
"(",
")",
"[",
"]",
"{",
"}",
",",
":",
"?",
"=",
"+",
"-",
"*",
"/",
"%",
"<",
">",
"!",
] as const
export type Mark = (typeof marks)[number]
export type Token =
| { readonly kind: "name"; readonly value: string }
| { readonly kind: "state"; readonly value: string }
| { readonly kind: "string"; readonly value: string }
| { readonly kind: "number"; readonly value: number }
| { readonly kind: "mark"; readonly value: Mark }
const number = /\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y
const name = /[A-Za-z_][A-Za-z0-9_]*/y
const escapes: Readonly<Record<string, string>> = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", "0": "\0" }
/**
* The tokens of one statement. A string still open at the end, as in a statement that is still streaming, ends the
* tokens and sets `open`; `error` names the first character that starts no token.
*/
export function tokenize(source: string): {
readonly tokens: readonly Token[]
readonly open: boolean
readonly error?: string
} {
const tokens: Token[] = []
let index = 0
while (index < source.length) {
const char = source.charAt(index)
if (/\s/u.test(char)) {
index++
continue
}
if (char === "#" || source.startsWith("//", index)) {
const end = source.indexOf("\n", index)
index = end === -1 ? source.length : end
continue
}
if (char === '"' || char === "'") {
const string = readString(source, index)
tokens.push({ kind: "string", value: string.value })
if (string.open) return { tokens, open: true }
index = string.end
continue
}
const digits = match(number, source, index)
if (digits) {
tokens.push({ kind: "number", value: Number(digits) })
index += digits.length
continue
}
const word = match(name, source, char === "$" ? index + 1 : index)
if (word) {
tokens.push(char === "$" ? { kind: "state", value: `$${word}` } : { kind: "name", value: word })
index += word.length + (char === "$" ? 1 : 0)
continue
}
const mark = marks.find((candidate) => source.startsWith(candidate, index))
if (!mark) return { tokens, open: false, error: `Unexpected character ${JSON.stringify(char)}` }
tokens.push({ kind: "mark", value: mark })
index += mark.length
}
return { tokens, open: false }
}
function match(pattern: RegExp, source: string, index: number) {
pattern.lastIndex = index
return pattern.exec(source)?.[0]
}
function readString(source: string, start: number) {
const quote = source.charAt(start)
const parts: string[] = []
let index = start + 1
while (index < source.length) {
const char = source.charAt(index)
if (char === quote) return { value: parts.join(""), end: index + 1, open: false }
if (char === "\n") break
if (char !== "\\") {
parts.push(char)
index++
continue
}
const next = source.charAt(index + 1)
if (next === "u") {
const hex = source.slice(index + 2, index + 6)
// A `\u` escape cut off mid-stream ends the string for now; it completes on the next update.
if (!/^[0-9a-fA-F]{4}$/u.test(hex)) break
parts.push(String.fromCharCode(Number.parseInt(hex, 16)))
index += 6
continue
}
if (!next) break
parts.push(escapes[next] ?? next)
index += 2
}
return { value: parts.join(""), end: source.length, open: true }
}
+220
View File
@@ -0,0 +1,220 @@
import { Result } from "effect"
import type { BinaryOperator, Expr, Issue, Program, Statement } from "./ast.js"
import { tokenize, type Mark, type Token } from "./lexer.js"
export interface Parsed {
readonly statement?: Statement
readonly issue?: Issue
}
/** Reuses each finished statement's parse across streaming updates, keyed by its line and text. */
export type Cache = Map<string, Parsed>
/**
* Parses a program. With `partial`, the last statement may still be streaming: anything left open at its end, such as
* a string, a call or a list, is closed where it stops instead of failing.
*/
export function parse(source: string, options: { readonly partial?: boolean; readonly cache?: Cache } = {}): Program {
const slices = split(source)
const parsed = slices.map((slice, index) => {
if (options.partial === true && index === slices.length - 1) return parseStatement(slice.text, slice.line, true)
const key = `${slice.line}\n${slice.text}`
const cached = options.cache?.get(key)
if (cached) return cached
const result = parseStatement(slice.text, slice.line, false)
options.cache?.set(key, result)
return result
})
return {
statements: parsed.flatMap((item) => (item.statement ? [item.statement] : [])),
issues: parsed.flatMap((item) => (item.issue ? [item.issue] : [])),
}
}
/** Each statement's source and first line. Statements end at a line break outside brackets; strings never span lines. */
export function split(source: string) {
const slices: { readonly text: string; readonly line: number }[] = []
let depth = 0
let quote = ""
let comment = false
let start = 0
let line = 1
let first = 1
for (let index = 0; index < source.length; index++) {
const char = source.charAt(index)
if (char === "\n") {
// A string open at a line break is a mistake; ending its statement here keeps the next lines intact.
if (quote) depth = 0
quote = ""
comment = false
if (depth === 0) {
slices.push({ text: source.slice(start, index), line: first })
start = index + 1
first = line + 1
}
line++
continue
}
if (comment) continue
if (quote) {
if (char === "\\" && source.charAt(index + 1) !== "\n") index++
else if (char === quote) quote = ""
continue
}
if (char === "#" || (char === "/" && source.charAt(index + 1) === "/")) comment = true
else if (char === '"' || char === "'") quote = char
else if (char === "(" || char === "[" || char === "{") depth++
else if (char === ")" || char === "]" || char === "}") depth = Math.max(0, depth - 1)
}
slices.push({ text: source.slice(start), line: first })
return slices.filter((slice) => slice.text.trim())
}
function parseStatement(text: string, line: number, partial: boolean): Parsed {
const lexed = tokenize(text)
const head = lexed.tokens[0]
const id = head?.kind === "name" || head?.kind === "state" ? head.value : undefined
const issue = (message: string): Parsed => (partial ? {} : { issue: { statement: id, line, message } })
if (lexed.tokens.length === 0 && !lexed.error) return {}
if (lexed.error) return issue(lexed.error)
if (lexed.open && !partial) return issue("A string is not closed. Strings must end on the line they start on.")
if (!id) return issue("Expected a statement: name = Expression")
if (!isMark(lexed.tokens[1], "=")) return issue(`Expected = after ${id}`)
if (lexed.tokens.length === 2) return issue(`${id} has no value`)
const expr = Result.try({
try: () => expression(lexed.tokens.slice(2), partial),
catch: (error) => (error instanceof SyntaxError ? error.message : String(error)),
})
if (Result.isFailure(expr)) return issue(expr.failure)
return { statement: { id, line, expr: expr.success } }
}
const levels: readonly (readonly BinaryOperator[])[] = [
["||"],
["&&"],
["==", "!="],
["<", ">", "<=", ">="],
["+", "-"],
["*", "/", "%"],
]
const nothing: Expr = { type: "literal", value: null }
/** Recursive descent over one statement's value. Throws a SyntaxError at the first token that does not fit. */
function expression(tokens: readonly Token[], partial: boolean): Expr {
let index = 0
const end = () => index >= tokens.length
// A statement still streaming may stop anywhere; the open structure closes where it stopped.
const stopped = () => partial && end()
const eat = (mark: Mark) => {
if (!isMark(tokens[index], mark)) return false
index++
return true
}
const fail = (message: string): never => {
throw new SyntaxError(message)
}
const unexpected = () => {
const token = tokens[index]
return token ? `Unexpected ${describe(token)}` : "Unexpected end of statement"
}
const conditional = (): Expr => {
const test = binary(0)
if (!eat("?")) return test
const then = conditional()
if (!eat(":"))
return stopped()
? { type: "conditional", test, then, otherwise: nothing }
: fail("Expected : in a ? : expression")
return { type: "conditional", test, then, otherwise: conditional() }
}
const binary = (level: number): Expr => {
const operators = levels[level]
if (!operators) return unary()
let left = binary(level + 1)
while (true) {
const token = tokens[index]
const operator = token?.kind === "mark" ? operators.find((candidate) => candidate === token.value) : undefined
if (!operator) return left
index++
if (stopped()) return left
left = { type: "binary", operator, left, right: binary(level + 1) }
}
}
const unary = (): Expr => {
if (eat("!")) return { type: "unary", operator: "!", operand: unary() }
if (eat("-")) return { type: "unary", operator: "-", operand: unary() }
return primary()
}
const primary = (): Expr => {
const token = tokens[index]
if (!token) return stopped() ? nothing : fail(unexpected())
index++
if (token.kind === "string" || token.kind === "number") return { type: "literal", value: token.value }
if (token.kind === "state") return { type: "state", name: token.value }
if (token.kind === "name") {
if (token.value === "true" || token.value === "false") return { type: "literal", value: token.value === "true" }
if (token.value === "null") return nothing
if (!eat("(")) return { type: "ref", name: token.value }
return { type: "call", name: token.value, args: sequence(")") }
}
if (token.value === "[") return { type: "list", items: sequence("]") }
if (token.value === "{") return { type: "object", entries: entries() }
if (token.value !== "(") return fail(`Unexpected ${describe(token)}`)
const inner = conditional()
if (!eat(")") && !stopped()) fail("Expected )")
return inner
}
const sequence = (close: Mark) => {
const items: Expr[] = []
while (!eat(close)) {
if (stopped()) return items
items.push(conditional())
if (eat(close) || stopped()) return items
if (!eat(","))
fail(`Expected , or ${close}, found ${end() ? "the end of the statement" : describe(tokens[index])}`)
}
return items
}
const entries = () => {
const result: (readonly [string, Expr])[] = []
while (!eat("}")) {
if (stopped()) return result
const key = tokens[index]
if (!key || (key.kind !== "name" && key.kind !== "string"))
return fail(`Expected an object key, found ${key ? describe(key) : "the end of the statement"}`)
index++
if (!eat(":")) return stopped() ? result : fail(`Expected : after ${key.value}`)
if (stopped()) return result
result.push([key.value, conditional()])
if (eat("}") || stopped()) return result
if (!eat(",")) fail("Expected , or }")
}
return result
}
const result = conditional()
if (!end()) fail(unexpected())
return result
}
function isMark(token: Token | undefined, mark: Mark) {
return token?.kind === "mark" && token.value === mark
}
function describe(token: Token | undefined) {
if (!token) return "nothing"
if (token.kind === "string") return `string ${JSON.stringify(token.value)}`
return `${token.value}`
}
+70
View File
@@ -0,0 +1,70 @@
export * as GenUISession from "./session.js"
import { Message } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { Session } from "@opencode/schema/session"
import { Effect, Stream } from "effect"
import { GenUI } from "./genui.js"
const enter = `<system-reminder>
The user is viewing this session in the OpenCode app. ${GenUI.RENDER_TOOL} shows them interactive UI, such as dashboards, charts and forms, drawn while you write it; load the genui skill for its syntax first. App-only tools such as the browser are in your tool list when they are available.
</system-reminder>`
const leave = `<system-reminder>
The user is no longer viewing this session in the OpenCode app, so ${GenUI.RENDER_TOOL} is unavailable. Answer in text.
</system-reminder>`
/**
* Offers the render tool, and tells the agent it may use it, only while the user is in the app: the app marks its
* prompts with `metadata.client = "app"`, so the latest prompt shows where the user is.
*/
export const register = Effect.fn("GenUISession.register")(function* (ctx: {
readonly session: Pick<Context["session"], "hook" | "synthetic">
readonly event: Context["event"]
}) {
// In memory only: after a restart a session counts as unknown, not left, until its next prompt.
const app = new Map<Session.ID, boolean>()
yield* ctx.session.hook("prompt", (event) =>
Effect.sync(() => {
app.set(event.sessionID, event.metadata?.client === "app")
}),
)
yield* ctx.session.hook("context", (event) => {
const viewing = app.get(event.sessionID)
if (viewing !== true) delete event.tools[GenUI.RENDER_TOOL]
const reminder = lastReminder(event.messages)
const text =
viewing === true && reminder !== enter ? enter : viewing === false && reminder === enter ? leave : undefined
if (!text) return Effect.void
// Before the user's prompt and near the tail, like Plan mode's reminders, so the cached prefix stays warm.
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
event.messages.splice(at, 0, Message.user(text))
return ctx.session
.synthetic({ sessionID: event.sessionID, text, resume: false })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to persist the GenUI reminder", { sessionID: event.sessionID, cause }),
),
)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted"),
Stream.runForEach((event) =>
Effect.sync(() => {
app.delete(event.data.sessionID)
}),
),
Effect.forkScoped({ startImmediately: true }),
)
})
function lastReminder(messages: ReadonlyArray<Message>) {
return messages.reduce<string | undefined>((found, message) => {
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
if (part?.type !== "text") return found
return part.text === enter || part.text === leave ? part.text : found
}, undefined)
}
+73
View File
@@ -0,0 +1,73 @@
export * as GenUITools from "./tools.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import { optional, AbsolutePath } from "@opencode/schema/schema"
import { Skill } from "@opencode/schema/skill"
import { Tool } from "@opencode/schema/tool"
import { Effect, Schema } from "effect"
import { GenUICatalog } from "./catalog.js"
import { GenUI } from "./genui.js"
import { GenUIGuide } from "./guide.js"
import type { GenUILang } from "./lang.js"
export const register = Effect.fn("GenUITools.register")(function* (ctx: Pick<Context, "tool" | "skill">) {
yield* ctx.skill.transform((editor) =>
editor.add(
Skill.Info.make({
id: Skill.ID.make("genui"),
name: Skill.Name.make("GenUI"),
description: `Syntax, components and examples for ${GenUI.RENDER_TOOL}. Load it before your first ${GenUI.RENDER_TOOL} call.`,
path: AbsolutePath.make("/builtin/genui.md"),
content: GenUIGuide.text,
}),
),
)
yield* ctx.tool
.transform((editor) =>
editor.add({
name: "render",
description: `Show the user interactive UI in the OpenCode app: dashboards, charts, plans, comparisons, pickers, forms and small stateful tools. It renders natively while you write it. Load the genui skill for the syntax first. Replies from buttons and forms arrive as the user's next message. Pass surfaceID from an earlier result to replace that surface. Every call is checked and its problems come back in the result.`,
// Property order is the order models write arguments in: the title and target first, so a client can place
// the surface before its program streams in.
input: Schema.Struct({
title: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(120)).annotate({
description: "Short title, e.g. 'Test results'.",
}),
surfaceID: optional(GenUI.SurfaceID).annotate({
description: "surfaceID of an earlier render to replace in place.",
}),
code: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(GenUI.MAX_CODE)).annotate({
description: "GenUI program. Starts with root = Surface(...). No Markdown fences.",
}),
}),
output: Schema.Struct({ surfaceID: GenUI.SurfaceID, issues: Schema.Array(Schema.String) }),
options: { namespace: "genui", permission: "genui", codemode: false },
execute: (input, tool) =>
Effect.gen(function* () {
const checked = GenUICatalog.check(GenUI.unfence(input.code))
const issues = checked.issues.map(describe)
if (!checked.root)
return yield* new Tool.Error({
message: ["Nothing rendered. Fix these problems and call genui_render again:", ...issues].join("\n- "),
})
const surfaceID = input.surfaceID ?? GenUI.surfaceFor(tool.id)
return {
output: { surfaceID, issues },
content:
issues.length === 0
? `Rendered surface ${surfaceID}.`
: [
`Rendered surface ${surfaceID} with ${issues.length} ${issues.length === 1 ? "problem" : "problems"}; the blocks they affect are not shown. Call ${GenUI.RENDER_TOOL} again with surfaceID ${surfaceID} and the fixed program:`,
...issues,
].join("\n- "),
}
}),
}),
)
.pipe(Effect.orDie)
})
function describe(issue: GenUILang.Issue) {
if (!issue.statement) return issue.message
return `${issue.statement} (line ${issue.line ?? "?"}): ${issue.message}`
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import { GenUICatalog } from "../src/catalog"
import { GenUIGuide } from "../src/guide"
import { GenUILang } from "../src/lang"
const render = (program: GenUILang.Program, state: GenUILang.State, memory?: Map<string, unknown>) =>
GenUILang.render(program, { catalog: GenUICatalog.catalog, root: GenUICatalog.Surface, state, memory })
/** Statement IDs of every rendered node in a tree. */
const rendered = (node: unknown): string[] => {
if (Array.isArray(node)) return node.flatMap(rendered)
if (node === null || typeof node !== "object") return []
const id = "statementId" in node && typeof node.statementId === "string" ? [node.statementId] : []
return [...id, ...Object.values(node).flatMap(rendered)]
}
describe("GenUILang", () => {
test.each(GenUICatalog.examples.map((example) => [example.title, example.code]))("%s checks clean", (_, code) => {
const checked = GenUICatalog.check(code)
expect(checked.issues).toEqual([])
expect(checked.root?.typeName).toBe("Surface")
})
// The surface is drawn from every prefix the model has written so far. No prefix may throw, and a block that has
// appeared keeps appearing while later lines stream in.
test.each(GenUICatalog.examples.map((example) => [example.title, example.code]))("%s streams", (_, code) => {
const parse = GenUILang.parser()
const memory = new Map<string, unknown>()
const start = GenUILang.initial(GenUILang.parse(code), GenUICatalog.catalog).state
const seen = new Set<string>()
for (let length = 1; length <= code.length; length++) {
const root = render(parse(code.slice(0, length), true), start, memory).root
const ids = new Set(rendered(root))
expect([...seen].filter((id) => !ids.has(id))).toEqual([])
ids.forEach((id) => seen.add(id))
}
expect(rendered(render(GenUILang.parse(code), start).root).toSorted()).toEqual([...seen].toSorted())
})
test("reports what an author can fix, by statement", () => {
const issues = GenUICatalog.check(`root = Surface([count, chart, row, missing, $nope])
$count = 0
count = Metric("Clicks", $cuont)
chart = BarChartt([1, 2, 3])
row = Row("Label")
spare = Text("never used")
plus = Button("+1", set(count, 1))`).issues
expect(issues.map((issue) => `${issue.statement}: ${issue.message}`)).toEqual([
"count: $cuont is not declared. Declare it with $cuont = <initial value> (declared: $count)",
"count: Metric needs value: Metric(label: string, value: string | number, detail?: string, delta?: number, trend?: number[], inverse?: boolean)",
"chart: BarChartt is not a component. Did you mean BarChart?",
"root: missing is used but never defined",
"root: $nope is not declared. Declare it with $nope = <initial value> (declared: $count)",
"root: Surface.children takes Block | Columns | Section, not Row; Row goes in Group",
"spare: spare is defined but nothing under root uses it, so it does not render",
"plus: set takes a $name: set($name, value)",
"plus: plus is defined but nothing under root uses it, so it does not render",
])
expect(issues.find((issue) => issue.statement === "chart")?.line).toBe(4)
})
test("reports syntax and type errors with the line they are on", () => {
const issues = GenUICatalog.check(`root = Surface([a, b])\na = Text("open\nb = Progress("Done", "most")`).issues
expect(issues.map((issue) => `${issue.line}: ${issue.message}`)).toEqual([
"2: A string is not closed. Strings must end on the line they start on.",
"1: a is used but never defined",
'3: Progress.value: Expected number, got "most"',
])
})
test("buttons update state in place and reply with it", () => {
const code = GenUICatalog.examples.find((example) => example.title.startsWith("Interactive"))?.code ?? ""
const program = GenUILang.parse(code)
const state = new Map(GenUILang.initial(program, GenUICatalog.catalog).state)
const actions = () => {
const root = render(program, state).root
const block = root?.props.children.find((child) => child.typeName === "Actions")
return block?.typeName === "Actions" ? block.props.items : []
}
const press = (label: string) => {
const button = actions().find((item) => item.typeName === "Button" && item.props.label === label)
const action = button?.typeName === "Button" ? button.props.action : undefined
if (!(action instanceof GenUILang.Action)) throw new Error(`${label} has no action`)
return GenUILang.perform(action, program, GenUICatalog.catalog, state)
}
const approve = press("Approve next")
expect(approve).toEqual({ type: "set", name: "$approved", value: 1 })
if (approve?.type === "set") state.set(approve.name, approve.value)
expect(press("Apply approved")).toEqual({ type: "reply", message: "Apply the 1 approved changes" })
state.set("$approved", 4)
expect(render(program, state).root?.props.children.map((child) => child.typeName)).toEqual([
"Metrics",
"Notice",
"Actions",
])
})
test("the guide prints every component's typed signature", () => {
expect(GenUIGuide.text).toContain(
"`Metric(label: string, value: string | number, detail?: string, delta?: number, trend?: number[], inverse?: boolean)`",
)
expect(GenUIGuide.text).toContain(
'`Button(label: string, action?: string | Action | Action[], variant?: "primary" | "secondary" | "ghost")`',
)
expect(GenUIGuide.text).toContain("`Surface(children: (Block | Columns | Section)[])`")
GenUICatalog.catalog.components.forEach((component) =>
expect(GenUIGuide.text).toContain(`\`${GenUILang.signature(component)}\``),
)
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { Message } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionContext, SessionPrompt } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Session } from "@opencode/schema/session"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Agent } from "@opencode/schema/agent"
import { DateTime, Effect, Stream } from "effect"
import { GenUI } from "../src/genui"
import { GenUISession } from "../src/session"
const sessionID = Session.ID.make("ses_genui_test")
/** Registers the plugin's session hooks against stubs, recording the reminders it persists. */
const run = async () => {
const persisted: string[] = []
const hooks: {
prompt?: (input: SessionPrompt) => Effect.Effect<void>
context?: (input: SessionContext) => Effect.Effect<void>
} = {}
const session: Pick<Context["session"], "hook" | "synthetic"> = {
hook: (name, callback) => {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
if (name === "prompt") hooks.prompt = callback as (input: SessionPrompt) => Effect.Effect<void>
if (name === "context") hooks.context = callback as (input: SessionContext) => Effect.Effect<void>
return Effect.succeed({ dispose: Effect.void })
},
synthetic: (input) => {
persisted.push(input.text)
return Effect.succeed(
SessionInbox.Synthetic.make({
id: SessionMessage.ID.make("msg_genui_test"),
sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "synthetic",
payload: { text: input.text },
delivery: "steer",
}),
)
},
}
await Effect.runPromise(
GenUISession.register({ session, event: { subscribe: () => Stream.empty } }).pipe(Effect.scoped),
)
const prompt = (metadata?: Record<string, unknown>) =>
Effect.runPromise(
hooks.prompt?.({
sessionID,
messageID: SessionMessage.ID.make("msg_prompt"),
prompt: { text: "hi" },
metadata,
delivery: "steer",
}) ?? Effect.void,
)
const context = async (messages: Message[]) => {
const request: SessionContext = {
sessionID,
agent: Agent.ID.make("build"),
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("test") },
system: [],
messages,
tools: { [GenUI.RENDER_TOOL]: { description: "", input: {} }, read: { description: "", input: {} } },
options: {},
}
await Effect.runPromise(hooks.context?.(request) ?? Effect.void)
return request
}
return { persisted, prompt, context }
}
describe("GenUISession", () => {
test("offers the render tool and tells the agent once while the user is in the app", async () => {
const plugin = await run()
await plugin.prompt({ client: "app" })
const first = await plugin.context([Message.user("Show me the build")])
expect(Object.keys(first.tools)).toEqual([GenUI.RENDER_TOOL, "read"])
expect(plugin.persisted).toHaveLength(1)
expect(first.messages.map((message) => message.content[0]?.type === "text" && message.content[0].text)).toEqual([
plugin.persisted[0],
"Show me the build",
])
const second = await plugin.context([Message.user(plugin.persisted[0] ?? ""), Message.user("Again")])
expect(plugin.persisted).toHaveLength(1)
expect(second.messages).toHaveLength(2)
})
test("hides the render tool and says so once the user prompts from elsewhere", async () => {
const plugin = await run()
await plugin.prompt({ client: "app" })
await plugin.context([Message.user("hi")])
await plugin.prompt()
const request = await plugin.context([Message.user(plugin.persisted[0] ?? ""), Message.user("From the terminal")])
expect(Object.keys(request.tools)).toEqual(["read"])
expect(plugin.persisted).toHaveLength(2)
expect(plugin.persisted[1]).toContain("no longer viewing")
})
test("never offers the render tool to a session the app has not prompted, such as a subagent", async () => {
const plugin = await run()
const request = await plugin.context([Message.user("Search the repo")])
expect(Object.keys(request.tools)).toEqual(["read"])
expect(plugin.persisted).toEqual([])
})
})
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"noEmit": false
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": ".", "noEmit": true },
"include": ["src", "test"]
}
+1
View File
@@ -16,6 +16,7 @@ const names = [
"client",
"plugin",
"plugin-browser",
"plugin-genui",
"core",
"simulation",
"server",
+2
View File
@@ -21,6 +21,7 @@
"./line-comment": "./src/components/line-comment.tsx",
"./line-comment-annotations": "./src/components/line-comment-annotations.tsx",
"./markdown": "./src/components/markdown.tsx",
"./genui": "./src/genui/surface.tsx",
"./message-part": "./src/components/message-part.tsx",
"./message-nav": "./src/components/message-nav.tsx",
"./session-review": "./src/components/session-review.tsx",
@@ -64,6 +65,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"@pierre/diffs": "catalog:",
+3
View File
@@ -50,6 +50,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
/** Sends a prompt as the user, e.g. a reply from an agent-rendered surface. Absent where the user cannot reply. */
onPrompt?: (input: { sessionID: string; text: string }) => Promise<void>
}) => {
return {
get store() {
@@ -65,6 +67,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sessionHref: props.onSessionHref,
shellRunning: props.shellRunning,
shellOutput: props.shellOutput,
prompt: props.onPrompt,
}
},
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+726
View File
@@ -0,0 +1,726 @@
import { createContext, createMemo, createRenderEffect, For, Show, useContext, type JSX } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { GenUICatalog } from "@opencode/plugin-genui/catalog"
import { GenUI } from "@opencode/plugin-genui/genui"
import { GenUILang } from "@opencode/plugin-genui/lang"
import { Button, type ButtonProps } from "@opencode/ui/button"
import { Divider } from "@opencode/ui/divider"
import { Icon } from "@opencode/ui/icon"
import type { IconName } from "@opencode/ui/icons/catalog"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { Textarea } from "@opencode/ui/textarea"
import { Markdown } from "../components/markdown"
import {
BarChart,
ComboChart,
Comparison,
Delta,
DonutChart,
Gauge,
Heatmap,
LineChart,
Meter,
Pips,
ProjectionChart,
RadarChart,
ScatterChart,
ShareBar,
Sparkline,
StatusStrip,
} from "./charts"
/** What a surface sends back when the user presses a button, a clickable row, or submits a form. */
export type GenUIReply = {
readonly message: string
/** Submitted form fields, by label, in form order. */
readonly values?: readonly (readonly [label: string, value: string])[]
}
type Node = GenUICatalog.Surface["props"]["children"][number]
type MetricNode = typeof GenUICatalog.Metric.Type
type StatusNode = typeof GenUICatalog.Status.Type
/** A button's or row's action: text to reply with, or actions to run in place. */
type Behaviour = (typeof GenUICatalog.Button.Type)["props"]["action"]
type Tone = (typeof GenUICatalog.tones)[number]
type Value = string | boolean
const Surface = createContext<{
readonly busy: () => boolean
/** Replies with text (or `label` when there is none), or runs the actions against the surface's state. */
readonly act: (behaviour: Behaviour, label: string, values?: GenUIReply["values"]) => void
readonly open: (url: string) => void
}>()
const Form = createContext<{
readonly value: (name: string) => Value | undefined
readonly set: (name: string, value: Value) => void
readonly values: () => GenUIReply["values"]
}>()
// The primary action is the contrast button, as in the app's own dialogs.
const buttonVariants = {
primary: "contrast",
secondary: "neutral",
ghost: "ghost",
} as const satisfies Record<(typeof GenUICatalog.buttonVariants)[number], ButtonProps["variant"]>
const noticeIcons: Record<Tone, IconName> = {
neutral: "info",
info: "info",
success: "circle-check",
warning: "warning",
danger: "circle-ban-sign",
}
/** The text a reply sends to the agent: the message, then the submitted values under `heading`. */
export function genuiReplyText(reply: GenUIReply, heading: string) {
const values = reply.values ?? []
return [
reply.message,
...(values.length ? ["", heading, ...values.map(([label, value]) => `- ${label}: ${value}`)] : []),
].join("\n")
}
/**
* Renders a GenUI program with OpenCode's own components. The program may still be streaming: each update parses
* only the statements that changed and reconciles by statement, so blocks already on screen stay mounted while new
* ones append below. Buttons can change the surface's state in place; only replies reach the agent.
*/
export function GenUISurface(props: {
code: string
/** The program is still being written. A block that does not render yet keeps showing its last version. */
streaming?: boolean
/** Turns off buttons, rows and form submission, e.g. while a reply is sending. */
busy?: boolean
onReply: (reply: GenUIReply) => void
onOpen: (url: string) => void
/** Shown when the program has no valid root yet. */
fallback?: JSX.Element
}) {
const parse = GenUILang.parser()
const memory = new Map<string, unknown>()
const program = createMemo(() => parse(GenUI.unfence(props.code), props.streaming === true))
const start = createMemo(() => GenUILang.initial(program(), GenUICatalog.catalog).state)
// Only the values the user changed; every other $name follows its declaration, including ones still streaming in.
const [store, setStore] = createStore<{
root?: GenUICatalog.Surface
changes: { name: string; value: GenUILang.Data }[]
}>({ changes: [] })
const state = (): GenUILang.State =>
new Map([...start(), ...store.changes.map((change) => [change.name, change.value] as const)])
// Syncs the rendered tree into a store so unchanged nodes keep their identity across stream and state updates.
createRenderEffect(() => {
const rendered = GenUILang.render(program(), {
catalog: GenUICatalog.catalog,
root: GenUICatalog.Surface,
state: state(),
memory: props.streaming ? memory : undefined,
})
setStore("root", reconcile(rendered.root ?? undefined, { key: "statementId", merge: true }))
})
const apply = (outcome: GenUILang.Outcome | undefined, values: GenUIReply["values"]) => {
if (outcome?.type === "set")
setStore("changes", (changes) => [
...changes.filter((change) => change.name !== outcome.name),
{ name: outcome.name, value: outcome.value },
])
if (outcome?.type === "reset")
setStore("changes", (changes) => changes.filter((change) => !outcome.names.includes(change.name)))
if (outcome?.type === "reply") props.onReply({ message: outcome.message, values })
if (outcome?.type === "open") props.onOpen(outcome.url)
}
// Each action sees the state the previous one left.
const run = (actions: readonly GenUILang.Action[], values: GenUIReply["values"]) =>
actions.forEach((action) => apply(GenUILang.perform(action, program(), GenUICatalog.catalog, state()), values))
const act = (behaviour: Behaviour, label: string, values?: GenUIReply["values"]) => {
if (behaviour instanceof GenUILang.Action) return run([behaviour], values)
if (isActionList(behaviour)) return run(behaviour, values)
props.onReply({ message: behaviour ?? label, values })
}
return (
<Surface.Provider value={{ busy: () => props.busy === true, act, open: props.onOpen }}>
<Show when={store.root} fallback={props.fallback}>
{(root) => (
<div data-component="genui-surface">
<Blocks items={root().props.children} />
</div>
)}
</Show>
</Surface.Provider>
)
}
/** Renders blocks in order. A run of Status lines shares one card, like the rows of a settings list. */
function Blocks(props: { items: readonly Node[] }) {
// Store nodes keep their identity, so `For` keeps every block mounted while the program grows.
const visible = createMemo(() =>
props.items.filter((item, index) => item.typeName !== "Status" || props.items[index - 1]?.typeName !== "Status"),
)
return (
<For each={visible()}>
{(node) => (isStatus(node) ? <StatusRun first={node} items={props.items} /> : <Block node={node} />)}
</For>
)
}
// `Array.isArray` cannot narrow a readonly array out of a union.
function isActionList(behaviour: Behaviour): behaviour is readonly GenUILang.Action[] {
return Array.isArray(behaviour)
}
function isStatus(node: Node): node is StatusNode {
return node.typeName === "Status"
}
function Block(props: { node: Node }): JSX.Element {
const node = props.node
switch (node.typeName) {
case "Section":
return (
<section data-component="genui-section">
<div data-slot="genui-section-heading">
<h3 data-slot="genui-section-title">{node.props.title}</h3>
<Show when={node.props.description}>
{(description) => <p data-slot="genui-section-description">{description()}</p>}
</Show>
</div>
<Blocks items={node.props.children} />
</section>
)
case "Text":
return (
<p data-component="genui-text" data-muted={node.props.muted || undefined}>
{node.props.text}
</p>
)
case "Markdown":
return <Markdown text={node.props.text} class="text-14-regular" />
case "Code":
return <Markdown text={fence(node.props.code, node.props.language ?? "")} />
case "Divider":
return <Divider />
case "Notice":
return (
<div data-component="genui-notice" data-tone={node.props.tone ?? "neutral"}>
<span data-slot="genui-notice-icon">
<Icon name={noticeIcons[node.props.tone ?? "neutral"]} />
</span>
<div data-slot="genui-notice-body">
<div data-slot="genui-label">{node.props.title}</div>
<Show when={node.props.description}>
{(description) => <p data-slot="genui-section-description">{description()}</p>}
</Show>
</div>
</div>
)
case "Status":
return <StatusRun first={node} items={[node]} />
case "Metrics":
return (
<div data-component="genui-card">
<div data-slot="genui-metrics">
<For each={node.props.items}>
{(metric) => (
<div data-slot="genui-metric">
<span data-slot="genui-metric-label">{metric.props.label}</span>
<span data-slot="genui-metric-headline">
<span data-slot="genui-metric-value">{String(metric.props.value)}</span>
<Show when={metric.props.trend}>
{(trend) => (
<Sparkline values={trend()} tone={deltaTone(metric.props.delta, metric.props.inverse)} />
)}
</Show>
</span>
<Show when={(metric.props.delta !== undefined && metric.props.delta !== null) || metric.props.detail}>
<span data-slot="genui-metric-foot">
<Show when={metric.props.delta ?? undefined}>
{(delta) => <Delta value={delta()} inverse={metric.props.inverse} />}
</Show>
<Show when={metric.props.detail}>
{(detail) => <span data-slot="genui-metric-detail">{detail()}</span>}
</Show>
</span>
</Show>
</div>
)}
</For>
</div>
</div>
)
case "Progress": {
const value = Math.min(100, Math.max(0, node.props.value))
return (
<div data-component="genui-progress">
<div data-slot="genui-progress-heading">
<span data-slot="genui-row-label">{node.props.label}</span>
<span data-slot="genui-row-value">{node.props.detail ?? `${Math.round(value)}%`}</span>
</div>
<div
data-slot="genui-progress-track"
role="progressbar"
aria-label={node.props.label}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
>
<div data-slot="genui-progress-fill" style={{ width: `${value}%` }} />
</div>
</div>
)
}
case "BarList": {
const max = Math.max(0, ...node.props.items.map((item) => item.value))
return (
<div data-component="genui-card">
<For each={node.props.items}>
{(item) => (
<div data-slot="genui-row">
<div
data-slot="genui-bar"
style={{ width: `${max > 0 ? (Math.max(0, item.value) / max) * 100 : 0}%` }}
/>
<span data-slot="genui-row-label">{item.label}</span>
<span data-slot="genui-row-value">{item.display ?? String(item.value)}</span>
</div>
)}
</For>
</div>
)
}
case "Table":
return (
<div data-component="genui-table">
<table>
<thead>
<tr>
<For each={node.props.columns}>{(column) => <th>{column}</th>}</For>
</tr>
</thead>
<tbody>
<For each={node.props.rows}>
{(row) => (
<tr>
<For each={row}>
{(cell) => <td data-numeric={Number.isFinite(cell) || undefined}>{String(cell)}</td>}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
)
case "Steps":
return (
<div data-component="genui-card">
<For each={node.props.items}>
{(item) => (
<div data-slot="genui-row" data-state={item.state ?? "pending"}>
<Show
when={item.state === "done"}
fallback={
<span
data-slot="genui-dot"
data-tone={item.state === "failed" ? "danger" : (item.state ?? "pending")}
/>
}
>
<span data-slot="genui-row-icon">
<Icon name="check-small" />
</span>
</Show>
<span data-slot="genui-row-label">{item.title}</span>
<Show when={item.detail}>{(detail) => <span data-slot="genui-row-value">{detail()}</span>}</Show>
</div>
)}
</For>
</div>
)
case "Group":
return (
<div data-component="genui-card">
<For each={node.props.rows}>{(row) => <GroupRow row={row} />}</For>
</div>
)
case "BarChart":
return (
<BarChart
labels={node.props.labels}
series={seriesOf(node.props.series)}
stacked={node.props.stacked}
format={node.props.format}
/>
)
case "LineChart":
return (
<LineChart
labels={node.props.labels}
series={seriesOf(node.props.series)}
format={node.props.format}
area={node.props.area}
/>
)
case "AreaChart":
return (
<LineChart
labels={node.props.labels}
series={seriesOf(node.props.series)}
format={node.props.format}
area
stacked={node.props.stacked}
/>
)
case "ComboChart":
return (
<ComboChart
labels={node.props.labels}
bars={node.props.bars.props}
line={node.props.line.props}
barFormat={node.props.barFormat}
lineFormat={node.props.lineFormat}
/>
)
case "ProjectionChart":
return (
<ProjectionChart
labels={node.props.labels}
actual={node.props.actual}
projected={node.props.projected}
target={node.props.target}
format={node.props.format}
targetLabel={node.props.targetLabel}
/>
)
case "DonutChart":
return <DonutChart items={node.props.items} center={node.props.center} format={node.props.format} />
case "ShareBar":
return <ShareBar items={node.props.items} format={node.props.format} />
case "Meter":
return (
<Meter label={node.props.label} value={node.props.value} detail={node.props.detail} tone={node.props.tone} />
)
case "Gauge":
return <Gauge label={node.props.label} value={node.props.value} detail={node.props.detail} />
case "Pips":
return (
<Pips label={node.props.label} value={node.props.value} detail={node.props.detail} tone={node.props.tone} />
)
case "StatusStrip":
return <StatusStrip items={node.props.items} start={node.props.start} end={node.props.end} />
case "Heatmap":
return (
<Heatmap
rows={node.props.rows}
columns={node.props.columns}
values={node.props.values}
format={node.props.format}
/>
)
case "RadarChart":
return <RadarChart axes={node.props.axes} series={seriesOf(node.props.series)} />
case "ScatterChart":
return (
<ScatterChart
points={node.props.points}
xLabel={node.props.xLabel}
yLabel={node.props.yLabel}
xFormat={node.props.xFormat}
yFormat={node.props.yFormat}
/>
)
case "Comparison":
return <Comparison columns={node.props.columns} rows={node.props.rows.map((row) => row.props)} />
case "Columns":
return (
<div data-component="genui-columns">
<Blocks items={node.props.children} />
</div>
)
case "Actions":
return <Actions node={node} />
case "Form":
return <FormBlock node={node} />
}
}
/** One card for a Status and the Status lines right after it, which `Blocks` skips. */
function StatusRun(props: { first: StatusNode; items: readonly Node[] }) {
const run = createMemo(() => {
const start = props.items.indexOf(props.first)
const end = props.items.findIndex((next, at) => at > start && next.typeName !== "Status")
return props.items.slice(start, end === -1 ? undefined : end).filter(isStatus)
})
return (
<div data-component="genui-card">
<For each={run()}>
{(status) => (
<div data-slot="genui-row">
<span data-slot="genui-dot" data-tone={status.props.tone} />
<span data-slot="genui-row-label">{status.props.label}</span>
<Show when={status.props.detail}>{(detail) => <span data-slot="genui-row-value">{detail()}</span>}</Show>
</div>
)}
</For>
</div>
)
}
function GroupRow(props: { row: typeof GenUICatalog.Row.Type }) {
const surface = useContext(Surface)
const content = () => (
<>
<span data-slot="genui-row-label">{props.row.props.label}</span>
<Show when={props.row.props.value}>{(value) => <span data-slot="genui-row-value">{value()}</span>}</Show>
</>
)
return (
<Show when={props.row.props.action} fallback={<div data-slot="genui-row">{content()}</div>}>
{(action) => (
<button
type="button"
data-slot="genui-row"
disabled={surface?.busy()}
onClick={() => surface?.act(action(), props.row.props.label)}
>
{content()}
<span data-slot="genui-row-icon">
<Icon name="chevron-right" size="small" />
</span>
</button>
)}
</Show>
)
}
function Actions(props: { node: typeof GenUICatalog.Actions.Type }) {
const surface = useContext(Surface)
const form = useContext(Form)
return (
<div data-component="genui-actions">
<For each={props.node.props.items}>
{(item) => (
<Show
when={item.typeName === "Button" ? item.props : undefined}
fallback={
<Show when={item.typeName === "Link" ? item.props : undefined}>
{(link) => (
<Button
size="normal"
variant="ghost"
icon="outline-arrow-up-right"
onClick={() => surface?.open(link().url)}
>
{link().label}
</Button>
)}
</Show>
}
>
{(button) => (
<Button
size="normal"
variant={buttonVariants[button().variant ?? "secondary"]}
disabled={surface?.busy()}
onClick={() => surface?.act(button().action, button().label, form?.values())}
>
{button().label}
</Button>
)}
</Show>
)}
</For>
</div>
)
}
function FormBlock(props: { node: typeof GenUICatalog.Form.Type }) {
const fields = () => props.node.props.fields
// Only what the user changed; a field's default comes from its current node, so fields that stream in later
// still start from theirs.
const [state, setState] = createStore<{ values: { name: string; value: Value }[] }>({ values: [] })
const value = (name: string) =>
state.values.find((entry) => entry.name === name)?.value ??
initial(fields().find((field) => field.props.name === name))
const set = (name: string, next: Value) =>
setState("values", (values) => [...values.filter((entry) => entry.name !== name), { name, value: next }])
return (
<Form.Provider
value={{
value,
set,
values: () =>
fields().flatMap((field) => {
const current = value(field.props.name)
return current === undefined || current === "" ? [] : [[field.props.label, String(current)] as const]
}),
}}
>
<form data-component="genui-form" onSubmit={(event) => event.preventDefault()}>
<For each={fields()}>{(field) => <Field field={field} />}</For>
<Actions node={props.node.props.actions} />
</form>
</Form.Provider>
)
}
function initial(field: GenUICatalog.Field | undefined): Value | undefined {
if (!field) return undefined
if (field.typeName === "Toggle") return field.props.value ?? false
return field.props.value ?? undefined
}
function Field(props: { field: GenUICatalog.Field }): JSX.Element {
const form = useContext(Form)
const field = props.field
const text = () => {
const current = form?.value(field.props.name)
return current === undefined || current === true || current === false ? "" : current
}
const hint = (value: string | null | undefined) => (
<Show when={value}>{(content) => <span data-slot="genui-hint">{content()}</span>}</Show>
)
switch (field.typeName) {
case "Input":
return (
<label data-slot="genui-field">
<span data-slot="genui-label">{field.props.label}</span>
<TextInput
class="!w-full"
placeholder={field.props.placeholder ?? undefined}
value={text()}
onInput={(event) => form?.set(field.props.name, event.currentTarget.value)}
/>
{hint(field.props.hint)}
</label>
)
case "TextArea":
return (
<label data-slot="genui-field">
<span data-slot="genui-label">{field.props.label}</span>
<Textarea
class="!w-full"
placeholder={field.props.placeholder ?? undefined}
value={text()}
onInput={(event) => form?.set(field.props.name, event.currentTarget.value)}
/>
{hint(field.props.hint)}
</label>
)
case "Select":
return (
<div data-slot="genui-field">
<span data-slot="genui-label">{field.props.label}</span>
<Select
class="!w-full"
options={[...field.props.options]}
current={text() || undefined}
onSelect={(option) => form?.set(field.props.name, option ?? "")}
/>
{hint(field.props.hint)}
</div>
)
case "Choice":
return (
// The group's own caption label is smaller than every other field's; show the shared field label instead.
<div data-slot="genui-field">
<span data-slot="genui-label">{field.props.label}</span>
<RadioGroup
label={field.props.label}
hideLabel
value={text()}
onChange={(option) => form?.set(field.props.name, option)}
>
<For each={field.props.options}>{(option) => <RadioItem value={option} label={option} />}</For>
</RadioGroup>
{hint(field.props.hint)}
</div>
)
case "Segmented":
return (
<div data-slot="genui-field">
<span data-slot="genui-label">{field.props.label}</span>
<SegmentedControl
class="segmented-control-v2--full-width"
value={text() || null}
onChange={(option) => form?.set(field.props.name, option ?? "")}
>
<For each={field.props.options}>
{(option) => <SegmentedControlItem value={option}>{option}</SegmentedControlItem>}
</For>
</SegmentedControl>
</div>
)
case "Toggle":
return (
<Switch
checked={form?.value(field.props.name) === true}
description={field.props.hint ?? undefined}
onChange={(checked) => form?.set(field.props.name, checked)}
>
{field.props.label}
</Switch>
)
}
}
/** Fences code with one more backtick than its longest run, so a code sample cannot close its own block. */
function fence(code: string, language: string) {
const ticks = "`".repeat(Math.max(3, ...[...code.matchAll(/`+/gu)].map((match) => match[0].length + 1)))
return `${ticks}${language}\n${code}\n${ticks}`
}
function deltaTone(delta: MetricNode["props"]["delta"], inverse: MetricNode["props"]["inverse"]) {
if (delta === undefined || delta === null || delta === 0) return undefined
return delta > 0 !== (inverse === true) ? ("success" as const) : ("danger" as const)
}
function seriesOf(series: readonly (typeof GenUICatalog.Series.Type)[]) {
return series.map((item) => item.props)
}
+64
View File
@@ -0,0 +1,64 @@
import { createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema } from "effect"
import { GenUI } from "@opencode/plugin-genui/genui"
import { useI18n } from "@opencode/ui/context/i18n"
import { Icon } from "@opencode/ui/icon"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { useData } from "../context/data"
import { ToolRegistry, type ToolProps } from "../tools/tool-renderer"
import { genuiReplyText, GenUISurface, type GenUIReply } from "./surface"
const decodeInput = Schema.decodeUnknownOption(GenUI.RenderInput)
/** A `genui_render` call in the timeline: the surface itself, drawn while its program streams in. */
function GenUITool(props: ToolProps) {
const i18n = useI18n()
const data = useData()
const input = createMemo(() => Option.getOrElse(decodeInput(props.input), (): GenUI.RenderInput => ({})))
const writing = () => props.status === "streaming" || props.status === "running" || props.status === "pending"
const [state, setState] = createStore({ sending: false })
const reply = (value: GenUIReply) => {
const prompt = data.prompt
const sessionID = data.sessionID
if (!prompt || !sessionID || state.sending) return
setState("sending", true)
const heading = i18n.t("ui.genui.submitted", { title: input().title ?? i18n.t("ui.genui.title") })
void prompt({ sessionID, text: genuiReplyText(value, heading) }).finally(() => setState("sending", false))
}
return (
<div data-component="genui-inline" data-writing={writing() || undefined}>
<div data-slot="genui-inline-header">
<span data-slot="genui-row-icon">
<Icon name="window-analytics" size="small" />
</span>
<span data-slot="genui-inline-title">{input().title ?? i18n.t("ui.genui.title")}</span>
<Show when={writing()}>
<span data-slot="genui-inline-status">
<TextShimmer text={i18n.t("ui.genui.writing")} active />
</span>
</Show>
</div>
<Show when={input().code}>
{(code) => (
<div data-slot="genui-inline-body">
<GenUISurface
code={code()}
streaming={writing()}
busy={!data.prompt || state.sending}
onReply={reply}
onOpen={(url) => {
if (/^https?:\/\//iu.test(url)) window.open(url, "_blank", "noopener,noreferrer")
}}
/>
</div>
)}
</Show>
</div>
)
}
ToolRegistry.register({ name: GenUI.RENDER_TOOL, render: GenUITool })
@@ -1,5 +1,6 @@
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode/client/promise"
import { Match, Switch, type ComponentProps } from "solid-js"
import { GenUI } from "@opencode/plugin-genui/genui"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import {
@@ -9,6 +10,9 @@ import {
ToolDisplay,
} from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
import { currentStreamingInput } from "./streaming-input"
// Registers the genui_render renderer with the tool registry.
import "../genui/tool"
export type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
@@ -83,7 +87,8 @@ export function SessionAssistantContent(props: {
<ToolDisplay
id={props.contentID}
tool={tool().name}
input={currentToolInput(tool())}
// Surfaces draw their arguments as they stream; every other tool waits for its complete input.
input={tool().name === GenUI.RENDER_TOOL ? currentStreamingInput(tool()) : currentToolInput(tool())}
metadata={currentToolMetadata(tool())}
output={currentToolOutput(tool())}
status={tool().state.status}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { partialStringFields } from "./partial-json"
describe("partialStringFields", () => {
test.each([
["nothing yet", "", {}],
["an open object", "{", {}],
["a key still streaming", '{"tit', {}],
["a value still streaming", '{"title":"Release re', { title: "Release re" }],
["completed fields before a streaming one", '{"title":"Plan","code":"root = Surface([a])\\na = Te', {
title: "Plan",
code: "root = Surface([a])\na = Te",
}],
["escapes, unicode and quotes", '{"code":"a \\"b\\" \\u00e9 \\\\ c"}', { code: 'a "b" é \\ c' }],
["an escape cut in half", '{"code":"line\\', { code: "line" }],
["a unicode escape cut in half", '{"code":"x\\u00', { code: "x" }],
["non-string values skipped", '{"count":3,"nested":{"a":"}"},"list":["x"],"code":"ok"}', { code: "ok" }],
])("%s", (_, raw, expected) => {
expect(partialStringFields(raw)).toEqual(expected)
})
})
@@ -0,0 +1,119 @@
const escapes = new Map([
['"', '"'],
["\\", "\\"],
["/", "/"],
["b", "\b"],
["f", "\f"],
["n", "\n"],
["r", "\r"],
["t", "\t"],
])
/**
* The top-level string fields of a JSON object that may still be streaming. A string cut off mid-stream keeps its
* decoded prefix, so a renderer can show an argument as the model writes it. Fields after the cut, and values that are
* not strings, are left out.
*/
export function partialStringFields(raw: string): Record<string, string> {
const fields = new Map<string, string>()
const cursor = { index: raw.indexOf("{") + 1 }
if (cursor.index === 0) return {}
const whitespace = () => {
while (/[\s,]/u.test(raw.charAt(cursor.index))) cursor.index++
}
// Reads the string starting at the opening quote under the cursor.
const string = () => {
const parts: string[] = []
cursor.index++
while (cursor.index < raw.length) {
const char = raw.charAt(cursor.index)
if (char === '"') {
cursor.index++
return { value: parts.join(""), closed: true }
}
if (char !== "\\") {
parts.push(char)
cursor.index++
continue
}
const next = raw.charAt(cursor.index + 1)
if (next === "u") {
const hex = raw.slice(cursor.index + 2, cursor.index + 6)
if (!/^[0-9a-fA-F]{4}$/u.test(hex)) break
parts.push(String.fromCharCode(Number.parseInt(hex, 16)))
cursor.index += 6
continue
}
const escaped = escapes.get(next)
if (escaped === undefined) break
parts.push(escaped)
cursor.index += 2
}
return { value: parts.join(""), closed: false }
}
// Skips a non-string value, stopping before the comma or brace that ends it.
const skip = () => {
const depth = { value: 0 }
while (cursor.index < raw.length) {
const char = raw.charAt(cursor.index)
if (char === '"') {
string()
continue
}
if (char === "{" || char === "[") depth.value++
if (char === "}" || char === "]") {
if (depth.value === 0) return
depth.value--
}
if (char === "," && depth.value === 0) return
cursor.index++
}
}
while (cursor.index < raw.length) {
whitespace()
if (raw.charAt(cursor.index) !== '"') break
const key = string()
if (!key.closed) break
whitespace()
if (raw.charAt(cursor.index) !== ":") break
cursor.index++
whitespace()
if (cursor.index >= raw.length) break
if (raw.charAt(cursor.index) !== '"') {
skip()
continue
}
const value = string()
fields.set(key.value, value.value)
if (!value.closed) break
}
return Object.fromEntries(fields)
}
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool } from "@opencode/client/promise"
import { currentStreamingInput } from "./streaming-input"
const tool = (input: string) =>
({
type: "tool",
id: "tool_streaming",
name: "genui_render",
state: { status: "streaming", input },
time: { created: 1 },
}) satisfies SessionMessageAssistantTool
describe("currentStreamingInput", () => {
test("shows string arguments as they stream", () => {
expect(currentStreamingInput(tool('{"title":"Plan","code":"root = Surface([a])\\na = Te'))).toEqual({
title: "Plan",
code: "root = Surface([a])\na = Te",
})
})
test("decodes complete JSON as usual", () => {
expect(currentStreamingInput(tool('{"title":"Plan","count":2}'))).toEqual({ title: "Plan", count: 2 })
})
})
@@ -0,0 +1,15 @@
import type { SessionMessageAssistantTool } from "@opencode/client/promise"
import { currentToolInput } from "./current-tool-state"
import { partialStringFields } from "./partial-json"
/**
* A tool call's input, including while it streams: while the JSON is incomplete, its string arguments as written so
* far. For tools whose renderer draws arguments as they arrive.
*/
export function currentStreamingInput(tool: SessionMessageAssistantTool) {
const input = currentToolInput(tool)
if (tool.state.status !== "streaming" || !tool.state.input || Object.keys(input).length > 0) return input
return partialStringFields(tool.state.input)
}
+1
View File
@@ -12,3 +12,4 @@
@import "../components/tool-count-summary.css" layer(components);
@import "../components/tool-error-card.css" layer(components);
@import "../components/tool-status-title.css" layer(components);
@import "../genui/genui.css" layer(components);
@@ -9,6 +9,7 @@ import type {
} from "@opencode/client/promise"
import { Effect, Option, Predicate, Schema } from "effect"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { GenUI } from "@opencode/plugin-genui/genui"
import {
currentContentDefaultOpen,
currentToolFailed,
@@ -887,6 +888,9 @@ function toolGroupType(
hasContextGroup: boolean,
detail?: TimelineDetail,
) {
// Agent-rendered surfaces are content, not activity: they always render inline.
if (content.name === GenUI.RENDER_TOOL && !currentToolFailed(content)) return undefined
if (detail) {
if (content.name === "question" && !currentToolFailed(content)) return undefined
const category = timelineCategory(content)!
+9
View File
@@ -191,6 +191,15 @@ const source = {
"ui.tool.grep": "Grep",
"ui.tool.task": "Task",
"ui.tool.browser": "Browser",
"ui.genui.title": "Generated UI",
"ui.genui.writing": "Writing",
"ui.genui.submitted": "Submitted in {{title}}:",
"ui.genui.total": "Total",
"ui.genui.actual": "Actual",
"ui.genui.projected": "Projected",
"ui.genui.target": "Target",
"ui.genui.less": "Less",
"ui.genui.more": "More",
"ui.tool.webfetch": "Webfetch",
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
+3
View File
@@ -80,6 +80,9 @@ await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin-browser ===\n")
await $`bun ./packages/plugin-browser/script/publish.ts`
console.log("\n=== plugin-genui ===\n")
await $`bun ./packages/plugin-genui/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`