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/src/server/routes/instance/httpapi/groups/control.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 33e6a8e4a0..3bac1b6de0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -2,16 +2,14 @@ import { Auth } from "@/auth" import { ProviderID } from "@/provider/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" const AuthParams = Schema.Struct({ providerID: ProviderID, }) -const LogQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), -}) +const LogQuery = withWorkspaceRouting({}) export const LogInput = Schema.Struct({ service: Schema.String.annotate({ description: "Service name for the log entry" }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3488d2616c..4b9a6a0f77 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -9,6 +9,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -42,7 +43,7 @@ const ToolListItem = Schema.Struct({ parameters: Schema.Unknown, }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) -export const ToolListQuery = Schema.Struct({ +export const ToolListQuery = withWorkspaceRouting({ provider: ProviderID, model: ModelID, }) @@ -54,7 +55,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( }), ) const WorktreeList = Schema.Array(Schema.String) -export const SessionListQuery = Schema.Struct({ +export const SessionListQuery = withWorkspaceRouting({ directory: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383..a4a11b3f95 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -6,17 +6,18 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" -export const FileQuery = Schema.Struct({ +export const FileQuery = withWorkspaceRouting({ path: Schema.String, }) -export const FindTextQuery = Schema.Struct({ +export const FindTextQuery = withWorkspaceRouting({ pattern: Schema.String, }) -export const FindFileQuery = Schema.Struct({ +export const FindFileQuery = withWorkspaceRouting({ query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -25,7 +26,7 @@ export const FindFileQuery = Schema.Struct({ ), }) -export const FindSymbolQuery = Schema.Struct({ +export const FindSymbolQuery = withWorkspaceRouting({ query: Schema.String, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05..9045747c9c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -9,6 +9,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from " import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -19,7 +20,7 @@ const PathInfo = Schema.Struct({ directory: Schema.String, }).annotate({ identifier: "Path" }) -export const VcsDiffQuery = Schema.Struct({ +export const VcsDiffQuery = withWorkspaceRouting({ mode: Vcs.Mode, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 967cc80206..43fc0bed3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { withWorkspaceRouting } from "../query" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -25,7 +26,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -export const ListQuery = Schema.Struct({ +export const ListQuery = withWorkspaceRouting({ directory: Schema.optional(Schema.String), scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), @@ -34,8 +35,8 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) -export const MessagesQuery = Schema.Struct({ +export const DiffQuery = withWorkspaceRouting(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const MessagesQuery = withWorkspaceRouting({ limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b1..a66483d34f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,6 +3,7 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../query" export const MessageGroup = HttpApiGroup.make("v2.message") .add( @@ -10,6 +11,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") params: { sessionID: SessionID }, query: Schema.Union([ Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), @@ -26,6 +28,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") cursor: Schema.optional(Schema.Never), }), Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3..3041d73ee1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -6,12 +6,14 @@ import { SessionV2 } from "@/v2/session" import { Schema, SchemaGetter } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../query" export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { query: Schema.Union([ Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), @@ -24,7 +26,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session") order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ description: "Session order for the first page. Use desc for newest first or asc for oldest first.", }), - directory: Schema.String.pipe(Schema.optional), path: Schema.String.pipe(Schema.optional), workspace: WorkspaceID.pipe(Schema.optional), roots: Schema.Literals(["true", "false"]) @@ -40,6 +41,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") cursor: Schema.optional(Schema.Never), }), Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check( Schema.isInt(), @@ -54,9 +56,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", }), order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), roots: Schema.optional(Schema.Never), start: Schema.optional(Schema.Never), search: Schema.optional(Schema.Never), diff --git a/packages/opencode/src/server/routes/instance/httpapi/query.ts b/packages/opencode/src/server/routes/instance/httpapi/query.ts new file mode 100644 index 0000000000..48432c44ce --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/query.ts @@ -0,0 +1,13 @@ +import { Schema } from "effect" + +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + +export function withWorkspaceRouting(fields: T) { + return Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...fields, + }) +} 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/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 0000000000..2d2d78d2ca --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { WithInstance } from "../../src/project/with-instance" +import { Session } from "@/session/session" +import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function pathFor(path: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) +} + +function createSession(directory: string, input?: Session.CreateInput) { + return Effect.promise( + async () => + await WithInstance.provide({ + directory, + fn: () => runSession(Session.Service.use((svc) => svc.create(input))), + }), + ) +} + +function createTextMessage(directory: string, sessionID: SessionIDType, text: string) { + return Effect.promise( + async () => + await WithInstance.provide({ + directory, + fn: () => + runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + const info = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text, + }) + return { info, part } + }), + ), + }), + ) +} + +function request(path: string, init?: RequestInit) { + return Effect.promise(async () => { + const { Server } = await import("../../src/server/server") + return Server.Default().app.request(path, init) + }) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +/** + * Reproducer for: runtime HttpApi query schemas must accept directory/workspace + * + * Previously, the OpenAPI spec was manually injected with directory/workspace query + * params (InstanceQueryParameters in public.ts), but the runtime query schemas + * did not include these fields. This caused a drift where: + * 1. Generated SDKs would send requests with ?directory=...&workspace=... + * 2. But runtime validation would reject these as unknown fields + * 3. Resulting in 400 Bad Request errors + * + * The fix adds directory/workspace to all instance route query schemas using the + * extendWithInstanceQuery helper in query.ts. + */ +describe("query schema drift fix", () => { + it.live( + "accepts directory and workspace query params on session.messages route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const session = yield* createSession(tmp.path, { title: "drift test" }) + yield* createTextMessage(tmp.path, session.id, "test message") + + // This should NOT return 400 - previously it would fail validation + // because MessagesQuery didn't include directory/workspace fields + const response = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1&directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request due to unknown query params + // Note: workspace param is omitted because an invalid workspace ID would cause 500 + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBe(1) + }), + ), + ) + + it.live( + "accepts directory and workspace query params on file.list route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + + // Create a test file + const testFile = `${tmp.path}/test.txt` + yield* Effect.promise(() => Bun.write(testFile, "test content")) + + // This should NOT return 400 - previously FileQuery didn't include directory/workspace + const response = yield* request( + `${FilePaths.list}?path=${encodeURIComponent(tmp.path)}&directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request + // Note: workspace param is omitted because an invalid workspace ID would cause 500 + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + }), + ), + ) + + it.live( + "accepts directory and workspace query params on session.list route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + yield* createSession(tmp.path, { title: "list drift test" }) + + // This should NOT return 400 - ListQuery already had directory but now includes workspace + // Use only directory parameter since invalid workspace ID would cause 500 + const response = yield* request( + `${SessionPaths.list}?directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBeGreaterThan(0) + }), + ), + ) + + it.live( + "accepts directory and workspace query params on find.file route", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + + // Create a test file + const testFile = `${tmp.path}/findme.txt` + yield* Effect.promise(() => Bun.write(testFile, "test content")) + + // This should NOT return 400 - FindFileQuery now includes directory/workspace + // Use only directory parameter since invalid workspace ID would cause 500 + const response = yield* request( + `${FilePaths.findFile}?query=findme&directory=${encodeURIComponent(tmp.path)}`, + { headers }, + ) + + // Should be 200 OK, not 400 Bad Request + expect(response.status).toBe(200) + + const body = yield* Effect.promise(() => response.json()) + expect(Array.isArray(body)).toBe(true) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 8a179a4dcc..0b03ad00a8 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -331,18 +331,36 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for safe instance routes", - withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) => + withProject("raw", { setup: writeStandardFiles }, ({ sdk }) => Effect.gen(function* () { const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) + const files = yield* call(() => sdk.file.list({ path: "." })) + const status = yield* call(() => sdk.file.status()) + const findText = yield* call(() => sdk.find.text({ pattern: "sdk-parity" })) + const findFiles = yield* call(() => sdk.find.files({ query: "hello", limit: 10 })) + const findSymbols = yield* call(() => sdk.find.symbols({ query: "hello" })) const session = yield* call(() => sdk.session.create({ title: "sdk" })) const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 })) + const messages = yield* call(() => sdk.session.messages({ sessionID: String(record(session.data).id), limit: 1 })) + const diff = yield* call(() => sdk.session.diff({ sessionID: String(record(session.data).id) })) + const vcsDiff = yield* call(() => sdk.vcs.diff({ mode: "git" })) + const tools = yield* call(() => sdk.tool.list({ provider: "opencode", model: "gpt-5" })) expect(file.response.status).toBe(200) expect(file.data).toMatchObject({ content: "hello" }) + expect(files.response.status).toBe(200) + expect(status.response.status).toBe(200) + expect(findText.response.status).toBe(200) + expect(findFiles.response.status).toBe(200) + expect(findSymbols.response.status).toBe(200) expect(session.response.status).toBe(200) expect(session.data).toMatchObject({ title: "sdk" }) expect(listed.response.status).toBe(200) expect(listed.data?.map((item) => item.id)).toContain(session.data?.id) + expect(messages.response.status).toBe(200) + expect(diff.response.status).toBe(200) + expect(vcsDiff.response.status).toBe(200) + expect(tools.response.status).toBe(200) yield* Effect.all([ expectStatus(() => sdk.project.current(), 200), @@ -464,9 +482,11 @@ describe("HttpApi SDK", () => { const fileStatus = yield* capture(() => sdk.file.status()) const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 })) const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" })) + const vcsDiff = yield* capture(() => sdk.vcs.diff({ mode: "git" })) const agents = yield* capture(() => sdk.app.agents()) const skills = yield* capture(() => sdk.app.skills()) const tools = yield* capture(() => sdk.tool.ids()) + const modelTools = yield* capture(() => sdk.tool.list({ provider: "opencode", model: "gpt-5" })) const vcs = yield* capture(() => sdk.vcs.get()) const formatter = yield* capture(() => sdk.formatter.status()) const lsp = yield* capture(() => sdk.lsp.status()) @@ -483,9 +503,11 @@ describe("HttpApi SDK", () => { fileStatus, findFiles, findText, + vcsDiff, agents, skills, tools, + modelTools, vcs, formatter, lsp, @@ -515,6 +537,7 @@ describe("HttpApi SDK", () => { const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID })) + const diff = yield* capture(() => sdk.session.diff({ sessionID: parentID })) const status = yield* capture(() => sdk.session.status()) const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID })) const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" })) @@ -535,6 +558,7 @@ describe("HttpApi SDK", () => { all, children, todo, + diff, status, messages, missingGet, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 96ddf8fcce..61cc5469a5 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -290,8 +290,10 @@ describe("session HttpApi", () => { ) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) - .items, + (yield* requestJson<{ items: SessionMessage.Message[] }>( + `/api/session/${parent.id}/message?directory=${encodeURIComponent(tmp.path)}`, + { headers }, + )).items, ).toMatchObject([{ type: "assistant" }]) }), ), @@ -364,8 +366,12 @@ describe("session HttpApi", () => { headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, { + headers: { "x-opencode-directory": tmp.path }, + }) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e3c5e83136..b81986b066 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -165,4 +165,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts workspace routing query params with paginated message requests", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const directory = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + const workspace = await app.request(`/session/${session.id}/message?limit=80&workspace=wrk_test`) + + expect(directory.status).toBe(200) + expect(workspace.status).toBe(200) + + await svc.remove(session.id) + }, + }), + ) + }) }) 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