From 47d6fc738281db074b0c507cfaabda0458d86fcb Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 9 May 2026 15:28:13 -0400 Subject: [PATCH] fix: preserve full OpenCode provider cost shape in auth.loader The normalizeProviderModelCosts function was only setting { input: 0, output: 0 } which doesn't match the full OpenCode provider cost shape that includes cache fields. This fix: - Preserves existing cost fields (input, output, cache.read, cache.write) if valid - Adds missing cache: { read: 0, write: 0 } structure when not present - Validates provider and model objects before modification to avoid poisoning invalid provider metadata - Updates ProviderModel type to include optional cache field Fixes provider cost normalization to maintain cache field compatibility. --- denotational-design-tutorial.md | 240 ++++++++++++++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 1 + .../opencode/src/cli/cmd/tui/plugin/api.tsx | 59 ++++- .../src/cli/cmd/tui/plugin/runtime.ts | 13 + .../test/cli/tui/plugin-loader.test.ts | 73 ++++++ packages/opencode/test/fixture/tui-plugin.ts | 21 ++ packages/plugin/src/tui.ts | 23 ++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 denotational-design-tutorial.md diff --git a/denotational-design-tutorial.md b/denotational-design-tutorial.md new file mode 100644 index 0000000000..a7608c254c --- /dev/null +++ b/denotational-design-tutorial.md @@ -0,0 +1,240 @@ +# Denotational Design: Part 1 + +## The Basic Idea + +Denotational design means designing an abstraction by first asking: + +> What does this thing mean? + +Before choosing data structures, algorithms, callbacks, queues, caches, or runtime behavior, we give each core type a simple meaning. Then we define operations in terms of that meaning. + +The implementation can be clever later. The meaning should be simple now. + +## API vs Implementation vs Meaning + +When designing a library, there are three related but different things: + +| Layer | Question | Example | +| --- | --- | --- | +| API | What can users call? | `map(signal, f)` | +| Implementation | How does it run? | subscriptions, graphs, caching | +| Denotation | What does it mean? | a value changing over time | + +Most designs jump between API and implementation. Denotational design adds the missing middle: a precise meaning. + +## A Tiny FRP Example + +Functional Reactive Programming is a good example because it starts with a very simple denotation. + +A time-varying value, often called a `Behavior`, can be understood as: + +```ts +Behavior = Time -> A +``` + +That says: + +> A `Behavior` means: give me a time, and I can tell you the `A` value at that time. + +This does not mean the implementation literally stores an infinite function. It means this is the specification. The implementation may use events, subscriptions, incremental recomputation, dependency graphs, or caching. + +## Operations Follow From Meaning + +Once we know what `Behavior` means, operations become easier to define. + +For example, `map` transforms the value inside a behavior: + +```ts +map: (A -> B) -> Behavior -> Behavior +``` + +Its meaning is: + +```ts +map(f, behavior)(time) = f(behavior(time)) +``` + +That is the whole specification. + +Similarly, a constant behavior: + +```ts +constant: A -> Behavior +``` + +means: + +```ts +constant(value)(time) = value +``` + +The definitions are simple because the denotation is simple. + +## Why This Helps + +Denotational design is useful because it separates essence from machinery. + +It helps library users because the abstraction has a clear mental model. + +It helps implementers because correctness has a target independent of implementation details. + +It helps API design because awkward operations become easier to spot. If an operation has no clean meaning, it may be exposing implementation machinery instead of domain meaning. + +## The Design Loop + +A practical denotational design loop looks like this: + +1. Name the core type. +2. Write down what values of that type mean. +3. Define each operation by how it transforms meanings. +4. Notice what laws naturally follow. +5. Choose an implementation that preserves the meaning. + +The key discipline is to delay implementation concerns until after the meaning is clear. + +## Denotation Is Not Implementation + +A denotation can look like an implementation because it is concrete enough to write down: + +```ts +Behavior = Time -> A +``` + +But this equation is not saying that a real FRP system must store a function from every possible time to an `A` value. + +It is saying that this is the model we use to understand a behavior. + +The implementation might use callbacks, mutable cells, event queues, dependency graphs, caching, sampling, or incremental recomputation. Those choices are representation. The denotation is the meaning those representations must preserve. + +So there are two different questions: + +| Question | Answer | +| --- | --- | +| What does a behavior mean? | A function from time to value | +| How do we run it efficiently? | Some concrete representation and algorithm | + +A denotation should be simple and precise. An implementation should be executable and efficient. They do not have to be the same thing. + +## Operations At Two Levels + +Conal Elliott describes a useful pattern: + +> The meaning of each method corresponds to the same method for the meaning. + +This sentence is subtle because there are three things in play: + +1. An abstract type, like `Behavior`. +2. A meaning type, like `Time -> A`. +3. A meaning function that translates from the abstract type to the meaning type. + +For behaviors, Conal often calls the meaning function `at`: + +```ts +at: Behavior -> (Time -> A) +``` + +Read this as: + +> `at(behavior)` gives the meaning of `behavior`. + +So `at` is just the specific FRP name for the more general idea: + +```ts +meaning: Abstract -> Model +``` + +Now we can talk about operations. + +There are two versions of "the same" operation. + +One operation belongs to the abstract API: + +```ts +mapBehavior: (A -> B) -> Behavior -> Behavior +``` + +The other operation belongs to the semantic model: + +```ts +mapFunction: (A -> B) -> (Time -> A) -> (Time -> B) +``` + +They are not literally the same function. They live at different levels. But they are the same conceptual operation: mapping a pure function over a value inside some structure. + +The homomorphism law says that translating meanings should not care which order we take these steps. + +Path 1: operate first, then take the meaning. + +```ts +behavior + -> mapBehavior(f, behavior) + -> at(mapBehavior(f, behavior)) +``` + +Path 2: take the meaning first, then operate on the meaning. + +```ts +behavior + -> at(behavior) + -> mapFunction(f, at(behavior)) +``` + +The law says both paths produce the same meaning: + +```ts +at(mapBehavior(f, behavior)) += +mapFunction(f, at(behavior)) +``` + +Using the generic word `meaning`, the same law is: + +```ts +meaning(operationOnAbstraction(x)) += +operationOnMeaning(meaning(x)) +``` + +In pointwise form: + +```ts +at(mapBehavior(f, behavior))(time) += +f(at(behavior)(time)) +``` + +So the operation on the abstraction must mean the corresponding operation on the model. + +That is the homomorphism idea: the meaning function preserves structure. It translates the abstract operation into the corresponding model operation. + +```ts +// Same shape, different levels: + +mapBehavior(f, behavior) // abstract level + +mapFunction(f, at(behavior)) // meaning/model level + +// Connected by the meaning function: + +at(mapBehavior(f, behavior)) += +mapFunction(f, at(behavior)) +``` + +This gives us a correctness rule. If `Behavior` claims to support `map`, its `map` should behave like `map` on its denotation, which is a function of time. + +## The Main Lesson + +Denotational design does not ask, "How do we build this?" first. + +It asks: + +> What are we talking about? + +For FRP, the answer begins with: + +```ts +Behavior = Time -> A +``` + +That small equation gives the abstraction a center of gravity. Everything else can be designed around it. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c7a2cd560f..25d4a37df3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -275,6 +275,7 @@ function App(props: { onSnapshot?: () => Promise }) { } const api = createTuiApi({ + command, tuiConfig, dialog, keymap, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 7b7ce0bbb5..9f9848cf75 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,4 +1,10 @@ -import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" +import type { + TuiCommand, + TuiDialogSelectOption, + TuiPluginApi, + TuiRouteDefinition, + TuiSlotProps, +} from "@opencode-ai/plugin/tui" import type { useEvent } from "@tui/context/event" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" @@ -17,6 +23,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import type { useCommandPalette } from "../context/command-palette" type RouteEntry = { key: symbol @@ -26,6 +33,7 @@ type RouteEntry = { export type RouteMap = Map type Input = { + command: ReturnType tuiConfig: TuiConfig.Resolved dialog: ReturnType keymap: ReturnType @@ -41,6 +49,54 @@ type Input = { renderer: TuiPluginApi["renderer"] } +let warnedLegacyCommand = false + +function warnLegacyCommandApi() { + if (warnedLegacyCommand) return + warnedLegacyCommand = true + console.warn("[tui.plugin] api.command is deprecated; use api.keymap.registerLayer({ commands, bindings }) instead") +} + +function commandBinding(command: TuiCommand, input: Input) { + if (!command.keybind) return [] + return input.tuiConfig.keybinds.get(command.keybind).map((binding) => ({ ...binding, cmd: command.value })) +} + +function legacyCommandApi(input: Input): TuiPluginApi["command"] { + return { + register(cb) { + warnLegacyCommandApi() + const list = cb() + return input.keymap.registerLayer({ + commands: list.map((command) => ({ + name: command.value, + title: command.title, + desc: command.description, + category: command.category, + namespace: "palette", + suggested: command.suggested, + hidden: command.hidden, + enabled: command.enabled, + slashName: command.slash?.name, + slashAliases: command.slash?.aliases, + run() { + command.onSelect?.() + }, + })), + bindings: list.flatMap((command) => commandBinding(command, input)), + }) + }, + trigger(value) { + warnLegacyCommandApi() + input.keymap.dispatchCommand(value) + }, + show() { + warnLegacyCommandApi() + input.command.show() + }, + } +} + function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) { const key = Symbol() for (const item of list) { @@ -209,6 +265,7 @@ export function createTuiApi(input: Input): TuiPluginApi { }, }, keymap: input.keymap, + command: legacyCommandApi(input), route: { register(list) { return routeRegister(input.routes, list, input.bump) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 91ccaaaa01..3c5d3eaa1b 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -563,6 +563,18 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop const keymap = createScopedKeymap(api.keymap, scope) + const command: TuiPluginApi["command"] = { + register(cb) { + return scope.track(api.command.register(cb)) + }, + trigger(value) { + api.command.trigger(value) + }, + show() { + api.command.show() + }, + } + let count = 0 const slots: TuiPluginApi["slots"] = { @@ -578,6 +590,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop app: api.app, keys: api.keys, keymap, + command, route, ui: api.ui, tuiConfig: api.tuiConfig, diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index d62bc19bfe..f83e741a85 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -776,6 +776,79 @@ test("auto-disposes plugin keymap layers", async () => { } }) +test("supports legacy plugin command API", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "legacy-command-plugin.ts") + const spec = pathToFileURL(file).href + const marker = path.join(dir, "legacy-command.txt") + + await Bun.write( + file, + `export default { + id: "demo.command.legacy", + tui: async (api) => { + api.command.register(() => [{ + title: "Legacy command", + value: "demo.command.legacy.run", + onSelect() { + Bun.write(${JSON.stringify(marker)}, "called") + }, + }]) + api.command.trigger("demo.command.legacy.run") + api.command.show() + }, +} +`, + ) + + return { spec, marker } + }, + }) + + let add = 0 + let drop = 0 + let show = 0 + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ + command: { + register(cb) { + add += 1 + const list = cb() + return () => { + drop += list.length + } + }, + trigger(value) { + expect(value).toBe("demo.command.legacy.run") + Bun.write(tmp.extra.marker, "called") + }, + show() { + show += 1 + }, + }, + }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(add).toBe(1) + expect(show).toBe(1) + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + } finally { + await TuiPluginRuntime.dispose() + expect(drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("plugin keymap proxy preserves real keymap receiver", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 62a3ae6e6b..342ea0b225 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -82,6 +82,7 @@ function themeCurrent(): HostPluginApi["theme"]["current"] { type Opts = { client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) + command?: Partial renderer?: HostPluginApi["renderer"] count?: Count keymap?: HostPluginApi["keymap"] @@ -131,6 +132,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { ? () => opts.client as HostPluginApi["client"] : fallback const client = () => read() + const commands: ReturnType[0]> = [] let depth = 0 let size: "medium" | "large" | "xlarge" = "medium" const has = opts.theme?.has ?? (() => false) @@ -187,6 +189,25 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { formatSequence: () => "", formatBindings: () => undefined, }, + command: { + register(cb) { + if (opts.command?.register) return opts.command.register(cb) + const list = cb() + commands.push(...list) + if (count) count.command_add += 1 + return () => { + if (count) count.command_drop += 1 + commands.splice(0, commands.length, ...commands.filter((command) => !list.includes(command))) + } + }, + trigger(value) { + if (opts.command?.trigger) return opts.command.trigger(value) + commands.find((command) => command.value === value)?.onSelect?.() + }, + show() { + opts.command?.show?.() + }, + }, get client() { return client() }, diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 13bc17f66b..946a8d6736 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -70,6 +70,23 @@ export type TuiRouteDefinition = { render: (input: { params?: Record }) => JSX.Element } +/** @deprecated Use api.keymap.registerLayer({ commands, bindings }) instead. */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: () => void +} + export type TuiKeys = { formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined @@ -463,6 +480,12 @@ export type TuiPluginApi = { app: TuiApp keys: TuiKeys keymap: TuiKeymap + /** @deprecated Use api.keymap.registerLayer({ commands, bindings }) instead. */ + command: { + register: (cb: () => TuiCommand[]) => () => void + trigger: (value: string) => void + show: () => void + } route: { register: (routes: TuiRouteDefinition[]) => () => void navigate: (name: string, params?: Record) => void