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