diff --git a/artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4 b/artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4 new file mode 100644 index 0000000000..7df912aad1 Binary files /dev/null and b/artifacts/tui-thinking-disclosure/thinking-flat-markdown-disclosure.mp4 differ diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 474d2d184d..8143c26352 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,7 +1,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { run } from "@opencode-ai/tui" -import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Config } from "../../config" @@ -9,6 +8,7 @@ import { Effect, Option } from "effect" import { Server } from "../../services/server" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" +import { Npm } from "@opencode-ai/core/npm" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -36,7 +36,7 @@ export default Runtime.handler(Commands, (input) => ) preflight.loading() const config = yield* Config.Service - let disposeSlots: (() => void) | undefined + const npm = yield* Npm.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const runPromise = Effect.runPromiseWith(context) @@ -44,9 +44,14 @@ export default Runtime.handler(Commands, (input) => server, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, config: { + path: config.path, get: () => runPromise(config.get()), update: (update) => runPromise(config.update(update)), }, + packages: { + resolve: (spec) => + runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), + }, terminalHandoff: () => preflight.finish(), log: (level, message, tags) => { const effect = @@ -59,14 +64,6 @@ export default Runtime.handler(Commands, (input) => : Effect.logInfo(message, tags) runFork(effect) }, - pluginHost: { - async start(pluginInput) { - disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime) - }, - async dispose() { - disposeSlots?.() - }, - }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) }), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index cffd80e772..a353a38a20 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -4,6 +4,7 @@ import { Spec } from "./spec" import { Global } from "@opencode-ai/core/global" import { Updater } from "../services/updater" import { Config } from "../config" +import { Npm } from "@opencode-ai/core/npm" export type Input = Value extends Spec.Node @@ -17,7 +18,7 @@ type RuntimeHandler = ( ) => Effect.Effect< void, unknown, - FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope + FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope > type Loader = () => Promise<{ default: ( @@ -25,7 +26,7 @@ type Loader = () => Promise<{ ) => Effect.Effect< void, any, - FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope + FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope > }> type ProvidedCommand = Command.Command< @@ -33,7 +34,7 @@ type ProvidedCommand = Command.Command< unknown, unknown, unknown, - FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope + FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope > export type Handlers = keyof Node["commands"] extends never diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2128495526..fd59472a86 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { AppProcess } from "@opencode-ai/core/process" import { Config } from "./config" +import { Npm } from "@opencode-ai/core/npm" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), @@ -54,7 +55,7 @@ Effect.logInfo("cli starting", { Effect.annotateLogs({ role: "cli" }), Effect.provide(Config.layer), Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))), Effect.provide(Observability.layer), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 7d30159126..df1aa9312a 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -1164,13 +1164,13 @@ export function createPromptState(input: PromptInput): PromptState { }, }, ], - bindings: input.tuiConfig.keybinds.gather("run.prompt.autocomplete", [ + bindings: [ "prompt.autocomplete.prev", "prompt.autocomplete.next", "prompt.autocomplete.hide", "prompt.autocomplete.select", "prompt.autocomplete.complete", - ]), + ].flatMap((command) => input.tuiConfig.keybinds.get(command)), })) const onKeyDown = (event: KeyEvent) => { diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff12..48e71da064 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -25,7 +25,10 @@ export interface EntryPoint { } export interface Interface { - readonly add: (pkg: string) => Effect.Effect + readonly add: ( + pkg: string, + options?: { readonly subpaths?: readonly string[] }, + ) => Effect.Effect readonly install: ( dir: string, input?: { @@ -47,13 +50,18 @@ export function sanitize(pkg: string) { return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } -const resolveEntryPoint = (name: string, dir: string): EntryPoint => { - let entrypoint: string | undefined - try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - } catch { - entrypoint = undefined - } +const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => { + const entrypoint = subpaths + .map((subpath) => { + try { + return typeof Bun !== "undefined" + ? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir) + : import.meta.resolve(dir) + } catch { + return undefined + } + }) + .find((entrypoint) => entrypoint !== undefined) return { directory: dir, entrypoint, @@ -112,7 +120,7 @@ const layer = Layer.effect( }), ) - const add = Effect.fn("Npm.add")(function* (pkg: string) { + const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) { const dir = directory(pkg) const name = (() => { try { @@ -123,17 +131,17 @@ const layer = Layer.effect( })() if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { - return resolveEntryPoint(name, path.join(dir, "node_modules", name)) + return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to if (!first) { - const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths) if (result.entrypoint) return result return yield* new InstallFailedError({ add: [pkg], dir }) } - return resolveEntryPoint(first.name, first.path) + return resolveEntryPoint(first.name, first.path, options?.subpaths) }, Effect.scoped) const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) { diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 007a53ed70..9b175a80b4 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -333,8 +333,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int tool: event.tool, sessionID: event.sessionID, agent: event.agent, - assistantMessageID: event.assistantMessageID, - toolCallID: event.toolCallID, + messageID: event.messageID, + callID: event.callID, input: event.input, } return Reflect.apply(callback, undefined, [output]).pipe( @@ -347,8 +347,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int tool: event.tool, sessionID: event.sessionID, agent: event.agent, - assistantMessageID: event.assistantMessageID, - toolCallID: event.toolCallID, + messageID: event.messageID, + callID: event.callID, input: event.input, result: event.result, output: event.output, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 75c8b300af..53caee351c 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -256,10 +256,22 @@ function fromPromiseTool(tool: AnyTool) { if ("jsonSchema" in tool) return Tool.make({ ...tool, - execute: (input, context) => Effect.promise(() => tool.execute(input, context)), + execute: (input, context) => + Effect.promise(() => + tool.execute(input, { + ...context, + progress: (update) => Effect.runPromise(context.progress(update)), + }), + ), }) return Tool.make({ ...tool, - execute: (input, context) => Effect.promise(() => tool.execute(input, context)), + execute: (input, context) => + Effect.promise(() => + tool.execute(input, { + ...context, + progress: (update) => Effect.runPromise(context.progress(update)), + }), + ), }) } diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index c29c6a8647..f41be2ad7c 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -54,12 +54,6 @@ const PluginModule = Schema.Struct({ ]), }) -const PluginPackage = Schema.Struct({ - exports: Schema.optional(Schema.Unknown), - main: Schema.optional(Schema.String), - module: Schema.optional(Schema.String), -}) - type Operation = | { readonly type: "add" @@ -165,7 +159,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract [])) - const directories = yield* fs - .glob("{plugin,plugins}/*", { - cwd: directory, - absolute: true, - include: "all", - dot: true, - symlink: true, - }) - .pipe( - Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })), - Effect.orElseSucceed(() => []), - ) - const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), { - concurrency: "unbounded", - }).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) - return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} })) + return files.sort().map((target): Operation => ({ type: "add", target, options: {} })) }) } -const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { - const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), - Effect.catch(() => Effect.succeed(undefined)), - ) - const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined - const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] - - return yield* Effect.forEach(entries, (entry) => { - if (!entry) return Effect.succeed(undefined) - const file = path.resolve(directory, entry) - return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) - }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) -}) - export interface Interface { /** Wait for the initial plugin generation and startup updates to settle. */ readonly flush: Effect.Effect diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index d20ad87b5b..1915bb21fc 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -30,6 +30,8 @@ export const prepare = Effect.fn("InstructionState.prepare")(function* ( SessionEvent.InstructionsUpdated, { sessionID, delta: admission.delta }, { + // Initial sync establishes the baseline; unlike later deltas it is not chronological history. + ...(!stored ? { metadata: { instructions: { initial: true } } } : {}), commit: () => insertBlobs(db, admission.blobs), }, ) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d18351b53e..48fcc4d8d6 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -51,6 +51,7 @@ import { AgentNotFoundError, StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { PluginSupervisor } from "../../plugin/supervisor" +import { Flag } from "../../flag/flag" type StepTokens = { readonly input: number @@ -197,6 +198,13 @@ const layer = Layer.effect( const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, + http: { + headers: { + "x-opencode-project": session.projectID, + "x-opencode-session": session.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }, + }, providerOptions: { openai: { promptCacheKey } }, system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial] .filter((part): part is string => part !== undefined && part.length > 0) @@ -257,8 +265,18 @@ const layer = Layer.effect( toolMaterialization.settle({ sessionID: session.id, agent: agent.id, - assistantMessageID, + messageID: assistantMessageID, call: event, + progress: (update) => + serialized( + events.publish(SessionEvent.Tool.Progress, { + sessionID: session.id, + assistantMessageID, + callID: event.id, + structured: { ...update.structured }, + content: [...update.content], + }), + ), }), ).pipe( Effect.flatMap((settlement) => diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 1030ff22ff..690f2cc85b 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -18,7 +18,7 @@ export const MANAGED_DIRECTORY = "tool-output" export interface BoundInput { readonly sessionID: SessionSchema.ID - readonly toolCallID: string + readonly callID: string readonly output: ToolOutput } diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 436a60e7ce..1d79459550 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -122,8 +122,8 @@ export const Plugin = { return Effect.gen(function* () { const permissionSource = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } if (input.oldString === input.newString) { return yield* new ToolFailure({ diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 89672dbfc6..701b5ff2bb 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -113,12 +113,13 @@ export const create = (registrations: ReadonlyMap) => { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) const output = yield* settle( registration.tool, - { type: "tool-call", id: context.toolCallID, name, input }, + { type: "tool-call", id: context.callID, name, input }, { sessionID: context.sessionID, agent: context.agent, - assistantMessageID: context.assistantMessageID, - toolCallID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, + progress: context.progress, }, ).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) const outputFileParts = outputFiles(output) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 3cde812aaa..f71102c362 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -72,7 +72,7 @@ export const Plugin = { }, sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, }) const cwd = path.resolve(location.directory, input.path ?? ".") yield* fs diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index cdf61866c6..99e64af50a 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -90,7 +90,7 @@ export const Plugin = { }, sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, }) const target = path.resolve(location.directory, input.path ?? ".") const info = yield* fs diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts index 4b491c57cd..abe829aa1b 100644 --- a/packages/core/src/tool/hooks.ts +++ b/packages/core/src/tool/hooks.ts @@ -12,8 +12,8 @@ export interface BeforeEvent { readonly tool: string readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string input: unknown } @@ -21,8 +21,8 @@ export interface AfterEvent { readonly tool: string readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string readonly input: unknown result: ToolResultValue output?: ToolOutput diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 8aad746ed6..0a15ba2350 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard( agent: context.agent, source: { type: "tool", - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, }, }) const result = yield* mcp diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 4d7c3030b9..8d986790ce 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -85,8 +85,8 @@ export const Plugin = { return Effect.gen(function* () { const source = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) const hunks = yield* Effect.try({ diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 41c9d2dd50..6b179fe9b8 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -73,7 +73,7 @@ export const Plugin = { resources: ["*"], sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, }) .pipe( Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })), @@ -84,7 +84,7 @@ export const Plugin = { title: "Questions", metadata: { kind: "question", - tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, + tool: { messageID: context.messageID, callID: context.callID }, }, fields: [ toField(input.questions[0], 0), diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index aa2e81b564..646e09daf6 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -62,8 +62,8 @@ export const Plugin = { return Effect.gen(function* () { const source = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) const external = target.externalDirectory diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index b1087c7822..6e1910ae56 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -19,8 +19,14 @@ import { toSessionError } from "../session/to-session-error" export type ExecuteInput = { readonly sessionID: SessionSchema.ID readonly agent: AgentV2.ID - readonly assistantMessageID: SessionMessage.ID + readonly messageID: SessionMessage.ID readonly call: ToolCall + readonly progress?: (update: Progress) => Effect.Effect +} + +export interface Progress { + readonly structured: Readonly> + readonly content: ToolOutput["content"] } export interface Interface { @@ -65,8 +71,8 @@ const registryLayer = Layer.effect( tool: input.call.name, sessionID: input.sessionID, agent: input.agent, - assistantMessageID: input.assistantMessageID, - toolCallID: input.call.id, + messageID: input.messageID, + callID: input.call.id, input: input.call.input, } yield* toolHooks.runBefore(beforeEvent) @@ -76,8 +82,22 @@ const registryLayer = Layer.effect( { sessionID: input.sessionID, agent: input.agent, - assistantMessageID: input.assistantMessageID, - toolCallID: input.call.id, + messageID: input.messageID, + callID: input.call.id, + progress: (update) => + input.progress?.({ + structured: update.structured, + content: (update.content ?? []).map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { + type: "file" as const, + uri: `data:${part.mime};base64,${part.data}`, + mime: part.mime, + name: part.name, + }, + ), + }) ?? Effect.void, }, ).pipe( Effect.map((output) => ({ output })), @@ -94,7 +114,7 @@ const registryLayer = Layer.effect( } else { const bounded = yield* resources.bound({ sessionID: input.sessionID, - toolCallID: input.call.id, + callID: input.call.id, output: pending.output, }) const result = ToolOutput.toResultValue(bounded.output) @@ -111,8 +131,8 @@ const registryLayer = Layer.effect( tool: input.call.name, sessionID: input.sessionID, agent: input.agent, - assistantMessageID: input.assistantMessageID, - toolCallID: input.call.id, + messageID: input.messageID, + callID: input.call.id, input: beforeEvent.input, result: settlement.result, output: settlement.output, diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 34583387d7..5109e2a535 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -165,8 +165,8 @@ export const Plugin = { Effect.gen(function* () { const source = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) const external = target.externalDirectory @@ -231,7 +231,7 @@ export const Plugin = { Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)), ) const job = yield* runtime.job.start({ - id: context.toolCallID, + id: context.callID, type: name, title: input.command, metadata: { sessionID: context.sessionID, shellID: info.id }, @@ -240,7 +240,7 @@ export const Plugin = { if (input.background === true) { yield* runtime.job.background(job.id) - yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) + yield* notifyWhenDone(context.sessionID, context.callID, input.command) return { output: BACKGROUND_STARTED, shellID: info.id, @@ -255,7 +255,7 @@ export const Plugin = { .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) if (result?.type === "backgrounded") { yield* shell.timeout(info.id, 0) - yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) + yield* notifyWhenDone(context.sessionID, context.callID, input.command) return { output: BACKGROUND_STARTED, shellID: info.id, diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 2589fd08ad..07d935a127 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -79,7 +79,7 @@ export const Plugin = { save: [skill.id], sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, }) const directory = path.dirname(skill.location) const files = diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 6fcda996dc..ea4dc87d5e 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -136,8 +136,8 @@ export const Plugin = { agent: context.agent, source: { type: "tool", - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, }, }) .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) @@ -160,6 +160,9 @@ export const Plugin = { ) const background = input.background === true + yield* context.progress({ + structured: { sessionID: child.id, status: "running" }, + }) const run = Effect.gen(function* () { // The child session owns its agent/model (set at create); prompt only admits input. diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 3b900e1784..6a5910c057 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -141,7 +141,7 @@ export const Plugin = { metadata: input, sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, }) const { body, contentType } = yield* Effect.gen(function* () { diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index ab65c33a4c..0e23fc8a32 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -213,7 +213,7 @@ export const Plugin = { metadata: { ...input, provider }, sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, }) const text = diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index d7cd8c54f4..e634411e01 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -64,8 +64,8 @@ export const Plugin = { Effect.gen(function* () { const source = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } const target = yield* mutation.resolve({ path: input.path, kind: "file" }) const external = target.externalDirectory diff --git a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts deleted file mode 100644 index 365d5566d2..0000000000 --- a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Plugin } from "@opencode-ai/plugin/v2" - -export default Plugin.define({ - id: "folder-plugin", - setup: async (ctx) => { - await ctx.agent.transform((agents) => { - agents.update("folder", (agent) => { - agent.description = "Loaded from plugin folder" - agent.mode = "subagent" - }) - }) - }, -}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 4c83d3bb4b..626e94f758 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -134,7 +134,7 @@ describe("PluginSupervisor config", () => { ), ) - it.live("loads auto-discovered plugin files and packages", () => + it.live("loads auto-discovered plugin files", () => withLocation( undefined, Effect.gen(function* () { @@ -143,9 +143,6 @@ describe("PluginSupervisor config", () => { expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({ description: "Loaded from plugin directory", }) - expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({ - description: "Loaded from plugin folder", - }) }), true, ), @@ -195,7 +192,6 @@ describe("PluginSupervisor config", () => { yield* ready() const agents = yield* AgentV2.Service expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined() - expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined() }), true, ), diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 10d68c7fa3..468f9e3d9c 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -10,7 +10,7 @@ import { host } from "../plugin/host" export const toolIdentity = { agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_tool_test"), + messageID: SessionMessage.ID.make("msg_tool_test"), } export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) => diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2fca0e0b95..f6b39e86aa 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -663,7 +663,7 @@ it.effect("waits for permission before calling an MCP tool", () => agent: toolIdentity.agent, source: { type: "tool", - messageID: toolIdentity.assistantMessageID, + messageID: toolIdentity.messageID, callID: "call_mcp_permission", }, }) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 7e4a5763bf..3a2a956299 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -41,19 +41,27 @@ describe("Npm.add", () => { await fs.mkdir(path.join(tmp.path, "fixture-provider")) await writePackage(path.join(tmp.path, "fixture-provider"), { name: "fixture-provider", - main: "index.js", + exports: { + ".": "./index.js", + "./tui": "./tui.js", + }, }) await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n") const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) - const entry = await Effect.gen(function* () { + const entries = await Effect.gen(function* () { const npm = yield* Npm.Service - return yield* npm.add(spec) + return { + tui: yield* npm.add(spec, { subpaths: ["tui", ""] }), + fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }), + } }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - expect(entry.entrypoint).toBeDefined() + expect(entries.tui.entrypoint).toEndWith("/tui.js") + expect(entries.fallback.entrypoint).toEndWith("/index.js") }) }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 6d9e0aac3d..9ff717d665 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -363,7 +363,7 @@ describe("PluginV2", () => { const settlement = yield* materialized.settle({ sessionID: SessionV2.ID.make("ses_hooks"), agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_hooks"), + messageID: SessionMessage.ID.make("msg_hooks"), call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } }, }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index afe72f6b59..2c6fccc892 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -129,6 +129,7 @@ describe("fromPromise", () => { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service const host = yield* PluginHost.make(plugins) + const progress: ToolRegistry.Progress[] = [] const promisePlugin = Plugin.define({ id: "promise-tool", setup: async (ctx) => { @@ -139,7 +140,10 @@ describe("fromPromise", () => { description: "Hello", input: Schema.Struct({ name: Schema.String }), output: Schema.String, - execute: async ({ name }) => `Hello, ${name}!`, + execute: async ({ name }, context) => { + await context.progress({ structured: { phase: "greeting" } }) + return `Hello, ${name}!` + }, }) }) }, @@ -153,10 +157,12 @@ describe("fromPromise", () => { yield* materialized.settle({ sessionID: SessionV2.ID.make("ses_promise_tool"), agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_promise_tool"), + messageID: SessionMessage.ID.make("msg_promise_tool"), + progress: (update) => Effect.sync(() => progress.push(update)), call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } }, }), ).toMatchObject({ result: { type: "text", value: "Hello, world!" } }) + expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }]) }), ) }) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index a682c1450e..1591dc4ed4 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -109,7 +109,7 @@ const it = testEffect(testLayer) const identity = { agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_nearby"), + messageID: SessionMessage.ID.make("msg_nearby"), } const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({ sessionID, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 1cdc71788a..1bfbe20557 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -182,7 +182,7 @@ test("binary failure emits no success event", async () => { test("success event data can carry a provider-executed result", () => { const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ sessionID, - assistantMessageID: SessionMessage.ID.create(), + messageID: SessionMessage.ID.create(), callID: "call-old", structured: { type: "media", mime: "image/png" }, content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index b7351cd900..c488c1f7fc 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -15,10 +15,10 @@ const bounds: ToolOutputStore.BoundInput[] = [] const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }) const outputStore = Layer.mock(ToolOutputStore.Service, { bound: (input) => { - if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure) + if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure) return Effect.sync(() => bounds.push(input)).pipe( Effect.as( - input.toolCallID === "call-bounded" + input.callID === "call-bounded" ? { output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] }, outputPaths: ["/managed/generic"], @@ -32,7 +32,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore. const it = testEffect(registryLayer) const identity = { agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_registry"), + messageID: SessionMessage.ID.make("msg_registry"), } const sessionID = SessionV2.ID.make("ses_registry") const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({ @@ -240,7 +240,9 @@ describe("ToolRegistry", () => { ...identity, call: { type: "tool-call", id: "call-context", name: "context", input: {} }, }) - expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }]) + expect(contexts).toEqual([ + { sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }, + ]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0ff2a86e84..5add9239ed 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -19,6 +19,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { Flag } from "@opencode-ai/core/flag/flag" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -782,14 +783,22 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({ query: Schema.String }), output: Schema.Struct({ answer: Schema.String }), execute: ({ query }, context) => - Effect.sync(() => { + Effect.gen(function* () { contexts.push(context) + yield* context.progress({ structured: { phase: "reading" } }) return { answer: query.toUpperCase() } }), }), }, { codemode: false }) yield* admit(session, "Use application context") responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] + const events = yield* EventV2.Service + const progressFiber = yield* events.subscribe(SessionEvent.Tool.Progress).pipe( + Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) yield* session.resume(sessionID) @@ -798,10 +807,12 @@ describe("SessionRunnerLLM", () => { { sessionID, agent: AgentV2.ID.make("build"), - assistantMessageID: expect.stringMatching(/^msg_/), - toolCallID: "call-location", + messageID: expect.stringMatching(/^msg_/), + callID: "call-location", + progress: expect.any(Function), }, ]) + expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" }) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Use application context" }, { @@ -942,6 +953,30 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("marks the initial instruction sync as baseline metadata", () => + Effect.gen(function* () { + const session = yield* setup + const events = yield* EventV2.Service + const instructionEvents: EventV2.Payload[] = [] + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + if (event.type === "session.instructions.updated") instructionEvents.push(event) + }), + ) + yield* admit(session, "First") + + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* admit(session, "Second") + yield* session.resume(sessionID) + yield* unsubscribe + + expect(instructionEvents).toHaveLength(2) + expect(instructionEvents[0]?.metadata).toEqual({ instructions: { initial: true } }) + expect(instructionEvents[1]?.metadata).toBeUndefined() + }), + ) + it.effect("retries the first request after system context becomes available", () => Effect.gen(function* () { const session = yield* setup @@ -2227,7 +2262,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) - expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }]) + expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }]) expect(executions).toEqual(["hello"]) const context = yield* session.context(sessionID) expect(context).toMatchObject([ @@ -3043,6 +3078,21 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("adds session correlation headers to model requests", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Run correlated request") + + yield* session.resume(sessionID) + + expect(requests[0]?.http?.headers).toEqual({ + "x-opencode-project": Project.ID.global, + "x-opencode-session": sessionID, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }) + }), + ) + it.effect("runs different sessions concurrently", () => Effect.gen(function* () { const session = yield* setup @@ -3838,6 +3888,32 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not retry eligible failures after observable output", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Do not replay partial output") + const failure = rateLimited() + responseStream = Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "partial-rate-limit" }), + LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }), + ]).pipe(Stream.concat(Stream.fail(failure))) + + expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(requests).toHaveLength(1) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1") + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { + type: "assistant", + finish: "error", + error: { type: "provider.rate-limit" }, + content: [{ type: "text", text: "Partial" }], + }, + ]) + }), + ) + it.effect("stops after five total retry attempts", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 6a1000fec4..3eedc6fbab 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -26,8 +26,9 @@ test("execute preserves successful results with visible unhandled rejections", a { sessionID: Session.ID.make("ses_execute"), agent: Agent.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_execute"), - toolCallID: "call_execute", + messageID: SessionMessage.ID.make("msg_execute"), + callID: "call_execute", + progress: () => Effect.void, }, ), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index a2d132a74b..4de63dfdcd 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -52,7 +52,7 @@ describe("ToolOutputStore", () => { const second = "y".repeat(30_000) + "-TAIL" const result = yield* store.bound({ sessionID, - toolCallID: "call-aggregate", + callID: "call-aggregate", output: { structured: { kind: "report" }, content: [ @@ -74,7 +74,7 @@ describe("ToolOutputStore", () => { withStore(({ store, fs }) => Effect.gen(function* () { const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } - const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } }) + const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } }) expect(result.output.structured).toEqual(structured) expect(result.outputPaths).toHaveLength(1) expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) @@ -89,7 +89,7 @@ describe("ToolOutputStore", () => { const data = "a".repeat(6 * 1024 * 1024) const result = yield* store.bound({ sessionID, - toolCallID: "call-file", + callID: "call-file", output: { structured: { caption: "pixel" }, content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }], @@ -120,7 +120,7 @@ describe("ToolOutputStore", () => { } const result = yield* store.bound({ sessionID, - toolCallID: "call-text-and-media", + callID: "call-text-and-media", output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] }, }) @@ -136,7 +136,7 @@ describe("ToolOutputStore", () => { Effect.gen(function* () { const text = "x".repeat(30_000) const output = { structured: { output: text }, content: [{ type: "text" as const, text }] } - expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({ + expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({ output, outputPaths: [], }) @@ -151,7 +151,7 @@ describe("ToolOutputStore", () => { const exit = yield* store .bound({ sessionID, - toolCallID: "call-lossy", + callID: "call-lossy", output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, }) .pipe(Effect.exit) @@ -166,7 +166,7 @@ describe("ToolOutputStore", () => { withStore(({ store }) => Effect.gen(function* () { const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] } - expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({ + expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({ output, outputPaths: [], }) @@ -197,7 +197,7 @@ describe("ToolOutputStore", () => { const fiber = yield* service .bound({ sessionID, - toolCallID: "call-interrupted", + callID: "call-interrupted", output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, }) .pipe(Effect.forkChild) @@ -216,7 +216,7 @@ describe("ToolOutputStore", () => { expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 }) const result = yield* store.bound({ sessionID, - toolCallID: "call-config", + callID: "call-config", output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] }, }) expect(result.outputPaths).toHaveLength(1) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 5d20d01622..1c91de9966 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -166,7 +166,7 @@ describe("QuestionTool", () => { expect(capturedInput()).toEqual({ sessionID, title: "Questions", - metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, + metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } }, fields: [ { key: "q0", @@ -212,7 +212,7 @@ describe("QuestionTool", () => { expect(capturedInput()).toEqual({ sessionID, title: "Questions", - metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, + metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } }, fields: [ { key: "q0", diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index e0250065c5..e9b4a5c56e 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -178,10 +178,12 @@ describe("SubagentTool", () => { const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) + const progress: ToolRegistry.Progress[] = [] const settled = yield* settleTool(registry, { sessionID: parent.id, ...toolIdentity, + progress: (update) => Effect.sync(() => progress.push(update)), call: { type: "tool-call", id: "call-subagent", @@ -192,6 +194,7 @@ describe("SubagentTool", () => { expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) const child = yield* sessions.get(outputSessionID(settled.output?.structured)) + expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ parentID: parent.id, location: parent.location, diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index e883a9e47d..1d920258df 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -164,9 +164,11 @@ packages/llm/src/ bedrock-converse.ts bedrock-event-stream.ts framing for AWS event-stream binary frames openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL + openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL utils/ per-protocol helpers (auth, cache, media, tool-stream, ...) providers/ - openai-compatible.ts generic compatible helper + family model helpers + openai-compatible.ts generic Chat helper + family model helpers + openai-compatible-responses.ts generic Responses helper openai-compatible-profile.ts family defaults (deepseek, togetherai, ...) azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts tool.ts typed tool() helper diff --git a/packages/llm/README.md b/packages/llm/README.md index 477d01701a..3a3aa0c389 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -104,7 +104,7 @@ const gateway = CloudflareAIGateway.configure({ }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct") ``` -Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. +Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. and a generic Responses entrypoint. ### Package-like entrypoints @@ -125,8 +125,9 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints: - `@opencode-ai/llm/providers/openai/chat` - `@opencode-ai/llm/providers/openai/responses` +- `@opencode-ai/llm/providers/openai-compatible/responses` -Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. +Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Anthropic, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. diff --git a/packages/llm/STATUS.md b/packages/llm/STATUS.md index 2994e59453..11d0025d20 100644 --- a/packages/llm/STATUS.md +++ b/packages/llm/STATUS.md @@ -13,20 +13,21 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A ## Current Implementation Snapshot -| Native slice | Source | Current state | Main gaps | -| ---------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | -| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | -| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | -| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | No OpenAI-compatible Responses protocol/facade. Family quirks are mostly endpoint defaults, not full typed behavior. | -| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | -| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | -| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | -| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | -| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | -| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | -| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | -| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | +| Native slice | Source | Current state | Main gaps | +| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | +| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | +| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | +| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | +| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. | +| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | +| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | +| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | +| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | +| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | +| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | +| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | +| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | ## V2 Runner Status @@ -45,7 +46,7 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh | AI SDK package | Intended native target | Status | Biggest gaps | | --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. | -| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat plus future Responses | Partial | Add OpenAI-compatible Responses. Decide per-family namespace/profile behavior for providers that support Responses versus Chat only. | +| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. | | `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. | | `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | | `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. | @@ -57,38 +58,38 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh ## Highest-Risk Gaps 1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. -2. OpenAI-compatible is Chat-only. We need a separate OpenAI-compatible Responses slice for providers/deployments that expose `/responses`, not an overloaded Chat route. +2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. 3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. 4. Vertex is not implemented natively. Google Gemini Developer API exists, but Vertex Gemini and Vertex Anthropic are separate auth/endpoint products and should be separate namespaces/facades. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. ## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| API slice | Package-like entrypoint | Purpose | -| --------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | -| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | -| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | -| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | -| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. | -| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | -| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | -| Vertex Gemini | Missing | Vertex Gemini API. | -| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | -| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | -| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | -| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | -| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | +| API slice | Package-like entrypoint | Purpose | +| --------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| OpenAI-compatible Responses | `@opencode-ai/llm/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. | +| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | Missing | Vertex Gemini API. | +| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | +| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices 1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close. -2. Implement `OpenAICompatibleResponses` as a separate protocol/route/facade instead of extending Chat. +2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. 4. Add Vertex Gemini and Vertex Anthropic native facades with ADC/OAuth auth and project/location endpoint derivation. 5. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. diff --git a/packages/llm/package.json b/packages/llm/package.json index cd14f7d523..3ca36b1310 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -28,6 +28,7 @@ "./providers/openai/responses": "./src/providers/openai/responses.ts", "./providers/openai/chat": "./src/providers/openai/chat.ts", "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai-compatible/responses": "./src/providers/openai-compatible-responses.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", @@ -37,6 +38,7 @@ "./protocols/gemini": "./src/protocols/gemini.ts", "./protocols/openai-chat": "./src/protocols/openai-chat.ts", "./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts", + "./protocols/openai-compatible-responses": "./src/protocols/openai-compatible-responses.ts", "./protocols/openai-responses": "./src/protocols/openai-responses.ts" }, "devDependencies": { diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 6117cd5c20..d61ae5c7bd 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -5,6 +5,7 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMError, LLMEvent, Usage, type CacheHint, @@ -19,7 +20,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" -import { isContextOverflow } from "../provider-error" +import { classifyProviderFailure } from "../provider-error" import * as Cache from "./utils/cache" import { Lifecycle } from "./utils/lifecycle" import { ToolSchemaProjection } from "./utils/tool-schema" @@ -832,15 +833,12 @@ const providerErrorMessage = (event: AnthropicEvent): string => { return message || type || "Anthropic Messages stream error" } -const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ - state, - [ - LLMEvent.providerError({ - message: providerErrorMessage(event), - classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined, - }), - ], -] +const onError = (event: AnthropicEvent) => + new LLMError({ + module: ADAPTER, + method: "stream", + reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }), + }) const step = (state: ParserState, event: AnthropicEvent) => { if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event)) @@ -848,7 +846,7 @@ const step = (state: ParserState, event: AnthropicEvent) => { if (event.type === "content_block_delta") return onContentBlockDelta(state, event) if (event.type === "content_block_stop") return onContentBlockStop(state, event) if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) - if (event.type === "error") return Effect.succeed(onError(state, event)) + if (event.type === "error") return onError(event) return Effect.succeed([state, NO_EVENTS]) } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 4984e32365..4ac3eeaf68 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -3,6 +3,7 @@ import { Route } from "../route/client" import { Endpoint } from "../route/endpoint" import { Protocol } from "../route/protocol" import { + LLMError, LLMEvent, Usage, type CacheHint, @@ -17,7 +18,7 @@ import { type ToolResultPart, } from "../schema" import { BedrockEventStream } from "./bedrock-event-stream" -import { isContextOverflow } from "../provider-error" +import { classifyProviderFailure } from "../provider-error" import { JsonObject, optionalArray, ProviderShared } from "./shared" import { BedrockAuth } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" @@ -586,27 +587,24 @@ const step = (state: ParserState, event: BedrockEvent) => return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const } - if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) { - const message = - event.internalServerException?.message ?? - event.modelStreamErrorException?.message ?? - event.serviceUnavailableException?.message ?? - "Bedrock Converse stream error" - return [state, [LLMEvent.providerError({ message })]] as const - } - - if (event.validationException || event.throttlingException) { - const message = - event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" - return [ - state, - [ - LLMEvent.providerError({ - message, - classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined, - }), - ], + const exception = ( + [ + ["internalServerException", event.internalServerException], + ["modelStreamErrorException", event.modelStreamErrorException], + ["serviceUnavailableException", event.serviceUnavailableException], + ["throttlingException", event.throttlingException], + ["validationException", event.validationException], ] as const + ).find((entry) => entry[1] !== undefined) + if (exception) { + return yield* new LLMError({ + module: ADAPTER, + method: "stream", + reason: classifyProviderFailure({ + message: exception[1]?.message ?? "Bedrock Converse stream error", + code: exception[0], + }), + }) } return [state, []] as const diff --git a/packages/llm/src/protocols/index.ts b/packages/llm/src/protocols/index.ts index bd8c8d3d9d..d00d517a09 100644 --- a/packages/llm/src/protocols/index.ts +++ b/packages/llm/src/protocols/index.ts @@ -3,4 +3,5 @@ export * as BedrockConverse from "./bedrock-converse" export * as Gemini from "./gemini" export * as OpenAIChat from "./openai-chat" export * as OpenAICompatibleChat from "./openai-compatible-chat" +export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/openai-compatible-responses.ts b/packages/llm/src/protocols/openai-compatible-responses.ts new file mode 100644 index 0000000000..2c56aadafa --- /dev/null +++ b/packages/llm/src/protocols/openai-compatible-responses.ts @@ -0,0 +1,23 @@ +import { Route, type RouteRoutedModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { OpenAIResponses } from "./openai-responses" + +const ADAPTER = "openai-compatible-responses" + +export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput + +/** + * Route for providers that expose an OpenAI Responses-compatible `/responses` + * endpoint. Provider helpers configure identity, endpoint, and auth before + * model selection while this route reuses the OpenAI Responses protocol. + */ +export const route = Route.make({ + id: ADAPTER, + providerMetadataKey: "openai", + protocol: OpenAIResponses.protocol, + endpoint: Endpoint.path(OpenAIResponses.PATH), + transport: OpenAIResponses.httpTransport, + defaults: { providerOptions: { openai: { store: false } } }, +}) + +export * as OpenAICompatibleResponses from "./openai-compatible-responses" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index c6d621f152..d03cb003ff 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -5,6 +5,7 @@ import { Endpoint } from "../route/endpoint" import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMError, LLMEvent, Usage, type FinishReason, @@ -19,7 +20,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" -import { isContextOverflow } from "../provider-error" +import { classifyProviderFailure } from "../provider-error" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" import { ToolSchemaProjection } from "./utils/tool-schema" @@ -606,9 +607,8 @@ type StepResult = readonly [ParserState, ReadonlyArray] const NO_EVENTS: StepResult["1"] = [] // `response.completed` / `response.incomplete` are clean finishes that emit a -// `finish` event; `response.failed` is a hard failure that emits a -// `provider-error`. All three end the stream — kept in one set so `step` and -// the protocol's `terminal` predicate stay in sync. +// `finish` event; `response.failed` is a hard failure. All three end the stream, +// so keep this set aligned with `step` and the protocol's terminal predicate. const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { @@ -910,22 +910,13 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st const providerError = (event: OpenAIResponsesEvent, fallback: string) => { const code = event.code || event.error?.code || event.response?.error?.code || undefined const message = providerErrorMessage(event, fallback) - return LLMEvent.providerError({ - message, - classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined, + return new LLMError({ + module: ADAPTER, + method: "stream", + reason: classifyProviderFailure({ message, code }), }) } -const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ - state, - [providerError(event, "OpenAI Responses response failed")], -] - -const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ - state, - [providerError(event, "OpenAI Responses stream error")], -] - const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) @@ -950,8 +941,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.output_item.done") return onOutputItemDone(state, event) if (event.type === "response.completed" || event.type === "response.incomplete") return Effect.succeed(onResponseFinish(state, event)) - if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event)) - if (event.type === "error") return Effect.succeed(onError(state, event)) + if (event.type === "response.failed") return providerError(event, "OpenAI Responses response failed") + if (event.type === "error") return providerError(event, "OpenAI Responses stream error") return Effect.succeed([state, NO_EVENTS]) } diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index 321bd7927e..bec21ac8b2 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -1,5 +1,18 @@ -import { Schema } from "effect" -import { LLMError, ProviderErrorEvent } from "./schema" +import { Option, Schema } from "effect" +import { + AuthenticationReason, + ContentPolicyReason, + InvalidRequestReason, + LLMError, + ProviderErrorEvent, + ProviderInternalReason, + QuotaExceededReason, + RateLimitReason, + UnknownProviderReason, + type HttpContext, + type HttpRateLimitDetails, + type ProviderMetadata, +} from "./schema" const patterns = [ /prompt is too long/i, @@ -31,3 +44,112 @@ export const isContextOverflowFailure = (failure: unknown) => failure instanceof LLMError ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) +const SERVER_CODES = new Set([ + "api_error", + "internal_error", + "internalserverexception", + "modelstreamerrorexception", + "overloaded_error", + "server_error", + "server_is_overloaded", + "serviceunavailableexception", +]) +const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"]) +const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i +const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i +const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i + +export interface ProviderFailure { + readonly message: string + readonly status?: number | undefined + readonly code?: string | undefined + readonly retryAfterMs?: number | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined + readonly http?: HttpContext | undefined + readonly providerMetadata?: ProviderMetadata | undefined +} + +// Keep HTTP failures and provider-reported stream failures on one typed path so +// session retry policy never needs provider-specific string matching. +export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] { + const body = input.http?.body ?? "" + const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)] + .filter((code): code is string => code !== undefined) + .map((code) => code.toLowerCase()) + const text = body || input.message + const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http } + const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500) + + if ( + clientScoped && + (codes.includes("context_length_exceeded") || + codes.includes("model_context_window_exceeded") || + isContextOverflow(text)) + ) + return new InvalidRequestReason({ ...common, classification: "context-overflow" }) + if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common) + if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) + return new QuotaExceededReason(common) + if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" }) + if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" }) + if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" }) + if (codes.includes("permission_error")) + return new AuthenticationReason({ ...common, kind: "insufficient-permissions" }) + if ( + codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception") + ) + return new RateLimitReason({ + ...common, + retryAfterMs: input.retryAfterMs, + rateLimit: input.rateLimit, + }) + if (RATE_LIMIT_TEXT.test(text)) + return new RateLimitReason({ + ...common, + retryAfterMs: input.retryAfterMs, + rateLimit: input.rateLimit, + }) + if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))) + return new ProviderInternalReason({ + ...common, + status: input.status, + retryAfterMs: input.retryAfterMs, + }) + if (input.status === 429) { + return new RateLimitReason({ + ...common, + retryAfterMs: input.retryAfterMs, + rateLimit: input.rateLimit, + }) + } + if (input.status !== undefined && input.status >= 500) + return new ProviderInternalReason({ + ...common, + status: input.status, + retryAfterMs: input.retryAfterMs, + }) + if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common) + if ( + input.status === 400 || + input.status === 404 || + input.status === 409 || + input.status === 413 || + input.status === 422 + ) + return new InvalidRequestReason(common) + return new UnknownProviderReason({ ...common, status: input.status }) +} + +function providerCodes(value: string) { + const decoded = Option.getOrUndefined(decodeJson(value)) + if (!isRecord(decoded)) return [] + const error = isRecord(decoded.error) ? decoded.error : undefined + return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts index 774274cf2d..042e825a51 100644 --- a/packages/llm/src/providers/index.ts +++ b/packages/llm/src/providers/index.ts @@ -7,5 +7,6 @@ export * as GitHubCopilot from "./github-copilot" export * as Google from "./google" export * as OpenAI from "./openai" export * as OpenAICompatible from "./openai-compatible" +export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenRouter from "./openrouter" export * as XAI from "./xai" diff --git a/packages/llm/src/providers/openai-compatible-responses.ts b/packages/llm/src/providers/openai-compatible-responses.ts new file mode 100644 index 0000000000..58b8ab65ae --- /dev/null +++ b/packages/llm/src/providers/openai-compatible-responses.ts @@ -0,0 +1,55 @@ +import type { ProviderPackage } from "../provider-package" +import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" +import type { OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("openai-compatible") + +export type Config = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly provider?: string + readonly baseURL: string + } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const routes = [OpenAICompatibleResponses.route] + +export const configure = (input: Config) => { + const provider = input.provider ?? "openai-compatible" + const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input + const route = OpenAICompatibleResponses.route.with({ + ...rest, + provider, + endpoint: { baseURL }, + auth: AuthOptions.bearer(input, []), + }) + return { + id: ProviderID.make(provider), + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + providerOptions: settings.providerOptions, + }).model(modelID) diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index a258aae944..2ef0db0222 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -10,7 +10,7 @@ import { WebSocketExecutor } from "./transport" import type { Protocol } from "./protocol" import { applyCachePolicy } from "../cache-policy" import * as ProviderShared from "../protocols/shared" -import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema" +import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema" import { GenerationOptions, HttpOptions, @@ -19,6 +19,7 @@ import { Model, ModelLimits, LLMError as LLMErrorClass, + LLMEvent, PreparedRequest, ProviderID, mergeGenerationOptions, @@ -229,6 +230,28 @@ const streamError = (route: string, message: string, cause: Cause.Cause return ProviderShared.eventError(route, message, Cause.pretty(cause)) } +const requireTerminalEvent = (route: string) => (events: Stream.Stream) => + Stream.suspend(() => { + let terminal = false + return events.pipe( + Stream.mapEffect((event) => { + if (terminal) + return Effect.fail( + ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal event`), + ) + if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true + return Effect.succeed(event) + }), + Stream.onEnd( + Effect.suspend(() => + terminal + ? Effect.void + : Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")), + ), + ), + ) + }) + function makeFromTransport( input: MakeTransportInput, ): Route { @@ -298,6 +321,7 @@ function makeFromTransport( protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, ), Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), + requireTerminalEvent(route), ) }, } satisfies Route diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index 0f7a949168..ab97bdaba8 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -8,21 +8,14 @@ import { HttpClientResponse, } from "effect/unstable/http" import { - AuthenticationReason, - ContentPolicyReason, HttpContext, HttpRateLimitDetails, HttpRequestDetails, HttpResponseDetails, - InvalidRequestReason, LLMError, - ProviderInternalReason, - QuotaExceededReason, - RateLimitReason, TransportReason, - UnknownProviderReason, } from "../schema" -import { isContextOverflow } from "../provider-error" +import { classifyProviderFailure } from "../provider-error" export interface Interface { readonly execute: ( @@ -85,8 +78,6 @@ const requestId = (headers: Record) => { ) } -const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529 - const retryAfterMs = (headers: Record) => { const millis = Number(headers["retry-after-ms"]) if (Number.isFinite(millis)) return Math.max(0, millis) @@ -219,58 +210,6 @@ const responseHttp = (input: { rateLimit: input.rateLimit, }) -const statusReason = (input: { - readonly status: number - readonly message: string - readonly retryAfterMs?: number | undefined - readonly rateLimit?: HttpRateLimitDetails | undefined - readonly http: HttpContext -}) => { - const body = input.http.body ?? "" - if (/content[-_\s]?policy|content_filter|safety/i.test(body)) { - return new ContentPolicyReason({ message: input.message, http: input.http }) - } - if (input.status === 401) { - return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http }) - } - if (input.status === 403) { - return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http }) - } - if (input.status === 429) { - if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) { - return new QuotaExceededReason({ message: input.message, http: input.http }) - } - return new RateLimitReason({ - message: input.message, - retryAfterMs: input.retryAfterMs, - rateLimit: input.rateLimit, - http: input.http, - }) - } - if ( - input.status === 400 || - input.status === 404 || - input.status === 409 || - input.status === 413 || - input.status === 422 - ) { - return new InvalidRequestReason({ - message: input.message, - classification: isContextOverflow(body) ? "context-overflow" : undefined, - http: input.http, - }) - } - if (input.status >= 500 || providerInternalStatus(input.status)) { - return new ProviderInternalReason({ - message: input.message, - status: input.status, - retryAfterMs: input.retryAfterMs, - http: input.http, - }) - } - return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http }) -} - const statusError = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => (response: HttpClientResponse.HttpClientResponse) => @@ -284,7 +223,7 @@ const statusError = return yield* new LLMError({ module: "RequestExecutor", method: "execute", - reason: statusReason({ + reason: classifyProviderFailure({ status: response.status, message: providerMessage(response.status, details), retryAfterMs: retryAfter, diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 3592ea729f..82acb7cb78 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -85,7 +85,7 @@ export class ContentPolicyReason extends Schema.Class("LLM. export class ProviderInternalReason extends Schema.Class("LLM.Error.ProviderInternal")({ _tag: Schema.tag("ProviderInternal"), message: Schema.String, - status: Schema.Number, + status: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number), providerMetadata: Schema.optional(ProviderMetadata), http: Schema.optional(HttpContext), diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index bbbb29f37a..912d89d1e6 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -105,6 +105,9 @@ const echoLayer = dynamicResponse(({ text, respond }) => ) const it = testEffect(echoLayer) +const unterminated = testEffect( + dynamicResponse(({ respond }) => Effect.succeed(respond(encodeJson([{ type: "text", text: "partial" }])))), +) describe("llm route", () => { it.effect("stream and generate use the route pipeline", () => @@ -125,6 +128,15 @@ describe("llm route", () => { }), ) + unterminated.effect("fails when the normalized stream ends without a terminal event", () => + Effect.gen(function* () { + const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip) + + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error.message).toContain("Provider stream ended without a terminal finish event") + }), + ) + it.effect("selects routes by model route value", () => Effect.gen(function* () { const llm = yield* LLMClient.Service diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts index 1227dbbf96..2a0056896f 100644 --- a/packages/llm/test/executor.test.ts +++ b/packages/llm/test/executor.test.ts @@ -107,6 +107,39 @@ describe("RequestExecutor", () => { }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), ) + it.effect("classifies provider rate limits hidden behind HTTP 400", () => + Effect.gen(function* () { + const classify = (body: string) => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "RateLimit" }) + }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) + + yield* classify("Request rate increased too quickly") + yield* classify('{"type":"error","error":{"type":"too_many_requests"}}') + yield* classify('{"type":"error","error":{"code":"rate_limit_exceeded"}}') + }), + ) + + it.effect("classifies provider overloads hidden behind HTTP 400", () => + Effect.gen(function* () { + const classify = (body: string) => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) + + yield* classify('{"code":"resource_exhausted"}') + yield* classify('{"code":"service_unavailable"}') + }), + ) + it.effect("returns redacted diagnostics for rate limits", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 0d25cc3dba..0ac6eb9bf2 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -11,7 +11,12 @@ import { XAI, } from "@opencode-ai/llm/providers" import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" -import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols" +import { + OpenAIChat, + OpenAICompatibleChat, + OpenAICompatibleResponses, + OpenAIResponses, +} from "@opencode-ai/llm/protocols" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" describe("public exports", () => { @@ -28,12 +33,17 @@ describe("public exports", () => { expect(Protocol.make).toBeFunction() }) - test("provider barrels expose user-facing facades", () => { + test("provider barrels expose user-facing facades", async () => { + const { OpenAICompatibleResponses } = await import("@opencode-ai/llm/providers") + expect(OpenAI.model).toBeFunction() expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() expect(OpenAICompatible.deepseek.model).toBeFunction() + expect( + OpenAICompatibleResponses.configure({ baseURL: "https://responses.test/v1" }).model("fixture").route.id, + ).toBe("openai-compatible-responses") expect(CloudflareAIGateway.configure).toBeFunction() expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction() expect(CloudflareWorkersAI.configure).toBeFunction() @@ -68,6 +78,7 @@ describe("public exports", () => { test("protocol barrels expose supported low-level routes", () => { expect(OpenAIChat.route.id).toBe("openai-chat") expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") + expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses") expect(OpenAIResponses.route.id).toBe("openai-responses") expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") expect(AnthropicMessages.route.id).toBe("anthropic-messages") diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts index 3622c89454..cfad7a8726 100644 --- a/packages/llm/test/provider-error.test.ts +++ b/packages/llm/test/provider-error.test.ts @@ -1,8 +1,54 @@ import { describe, expect, test } from "bun:test" import { isContextOverflow } from "../src" +import { classifyProviderFailure } from "../src/provider-error" describe("provider error classification", () => { test("classifies Z.AI GLM token limit messages as context overflow", () => { expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true) }) + + test("classifies V1 plain-text rate limit fallbacks", () => { + expect( + [ + "Request rate increased too quickly", + "Rate limit exceeded, please try again later", + "Too many requests, please slow down", + ].map((message) => classifyProviderFailure({ message })._tag), + ).toEqual(["RateLimit", "RateLimit", "RateLimit"]) + }) + + test("classifies V1 JSON rate limit fallbacks", () => { + expect( + [ + '{"type":"error","error":{"type":"too_many_requests"}}', + '{"type":"error","error":{"code":"rate_limit_exceeded"}}', + '{"code":"bad_request","error":{"code":"rate_limit_exceeded"}}', + '{"type":"error","error":{"code":"unknown","type":"too_many_requests"}}', + ].map((message) => classifyProviderFailure({ message })._tag), + ).toEqual(["RateLimit", "RateLimit", "RateLimit", "RateLimit"]) + }) + + test("classifies V1 overloaded provider codes", () => { + expect( + ['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map( + (message) => classifyProviderFailure({ message })._tag, + ), + ).toEqual(["ProviderInternal", "ProviderInternal"]) + }) + + test("classifies nested provider codes when a top-level code is also present", () => { + expect( + [ + '{"code":"bad_request","error":{"code":"usage_not_included"}}', + '{"code":"bad_request","error":{"code":"server_error"}}', + '{"code":"bad_request","error":{"type":"invalid_request_error"}}', + ].map((message) => classifyProviderFailure({ message })._tag), + ).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"]) + }) + + test("keeps unknown and malformed provider payloads non-retryable", () => { + expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider") + expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider") + expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider") + }) }) diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index 346b6b86ee..0428c02eec 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -9,6 +9,7 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/openai/chat"), import("@opencode-ai/llm/providers/anthropic"), import("@opencode-ai/llm/providers/openai-compatible"), + import("@opencode-ai/llm/providers/openai-compatible/responses"), import("@opencode-ai/llm/providers/amazon-bedrock"), import("@opencode-ai/llm/providers/azure"), import("@opencode-ai/llm/providers/azure/responses"), @@ -18,7 +19,7 @@ describe("provider package entrypoints", () => { for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) - expect(modules[6].model).toBe(modules[7].model) + expect(modules[7].model).toBe(modules[8].model) }) test("maps package settings onto the executable model", () => { @@ -42,6 +43,32 @@ describe("provider package entrypoints", () => { expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket") }) + test("maps OpenAI-compatible Responses settings onto the executable model", async () => { + const OpenAICompatibleResponses = await import("@opencode-ai/llm/providers/openai-compatible/responses") + const selected = OpenAICompatibleResponses.model("custom-model", { + apiKey: "fixture", + baseURL: "https://responses.example.test/v1", + provider: "example", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + providerOptions: { openai: { reasoningEffort: "low", store: true } }, + }) + + expect(String(selected.provider)).toBe("example") + expect(selected.route.id).toBe("openai-compatible-responses") + expect(selected.route.endpoint).toMatchObject({ + baseURL: "https://responses.example.test/v1", + path: "/responses", + }) + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(selected.route.defaults.providerOptions).toEqual({ + openai: { reasoningEffort: "low", store: true }, + }) + }) + test("maps legacy OpenAI organization and project settings to headers", () => { const selected = model("gpt-5", { apiKey: "fixture", diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 14a35a4fa7..dcb20aa0ae 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -484,23 +484,22 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("emits provider-error events for mid-stream provider errors", () => + it.effect("fails with a typed provider error for stream error frames", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })), ), + Effect.flip, ) - // Prefix the error type so consumers can distinguish overloads, rate - // limits, and quota errors without parsing the message string. - expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }]) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error: Overloaded" }) }), ) it.effect("classifies prompt-too-long provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -509,35 +508,36 @@ describe("Anthropic Messages route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "invalid_request_error: prompt is too long: 210000 tokens", - classification: "context-overflow", - }, - ]) + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "invalid_request_error: prompt is too long: 210000 tokens", + classification: "context-overflow", + }) }), ) it.effect("falls back to error type when no message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }]) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error" }) }), ) it.effect("falls back to a stable default when error payload is absent", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }]) + expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Anthropic Messages stream error" }) }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 87186949ae..c7b519d368 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -355,31 +355,29 @@ describe("Bedrock Converse route", () => { }), ) - it.effect("emits provider-error for throttlingException", () => + it.effect("classifies throttlingException as a rate limit", () => Effect.gen(function* () { const body = eventStreamBody( ["messageStart", { role: "assistant" }], ["throttlingException", { message: "Slow down" }], ) - const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip) - expect(response.events.find((event) => event.type === "provider-error")).toEqual({ - type: "provider-error", - message: "Slow down", - }) + expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" }) }), ) it.effect("classifies input-too-long validation exceptions", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(baseRequest).pipe( + const error = yield* LLMClient.generate(baseRequest).pipe( Effect.provide( fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])), ), + Effect.flip, ) - expect(response.events.find((event) => event.type === "provider-error")).toEqual({ - type: "provider-error", + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", message: "Input is too long for requested model", classification: "context-overflow", }) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 63ae09bdd8..a08a22b037 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -602,7 +602,7 @@ describe("OpenAI Chat route", () => { }), ) - it.effect("does not finalize streamed tool calls without a finish reason", () => + it.effect("fails a streamed tool call when the provider ends without a finish reason", () => Effect.gen(function* () { const body = sseEvents( deltaChunk({ @@ -614,8 +614,11 @@ describe("OpenAI Chat route", () => { const input = LLM.updateRequest(request, { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }) - const events = Array.from( - yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))), + const events: LLMEvent[] = [] + const streamError = yield* LLMClient.stream(input).pipe( + Stream.runForEach((event) => Effect.sync(() => events.push(event))), + Effect.flip, + Effect.provide(fixedResponse(body)), ) const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) @@ -626,6 +629,8 @@ describe("OpenAI Chat route", () => { { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(streamError.message).toContain("Provider stream ended without a terminal finish event") expect(error.message).toContain("Provider stream ended without a terminal finish event") }), ) diff --git a/packages/llm/test/provider/openai-compatible-responses.test.ts b/packages/llm/test/provider/openai-compatible-responses.test.ts new file mode 100644 index 0000000000..a43acb7683 --- /dev/null +++ b/packages/llm/test/provider/openai-compatible-responses.test.ts @@ -0,0 +1,53 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { configure } from "../../src/providers/openai-compatible-responses" +import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" +import { OpenAIResponses } from "../../src/protocols/openai-responses" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" + +describe("OpenAI-compatible Responses route", () => { + it.effect("reuses the OpenAI Responses protocol for a configured deployment", () => + Effect.gen(function* () { + expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body) + expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport) + + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + provider: "example", + }).model("example-model") + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: "You are concise.", + prompt: "Say hello.", + }), + ) + + expect(prepared.route).toBe("openai-compatible-responses") + expect(prepared.protocol).toBe("openai-responses") + expect(prepared.model).toMatchObject({ + id: "example-model", + provider: "example", + route: { + id: "openai-compatible-responses", + endpoint: { + baseURL: "https://responses.example.test/v1", + path: "/responses", + }, + }, + }) + expect(prepared.body).toEqual({ + model: "example-model", + input: [ + { role: "system", content: "You are concise." }, + { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, + ], + store: false, + stream: true, + }) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 421617b6db..7548340690 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1368,37 +1368,37 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("emits provider-error events for mid-stream provider errors", () => + it.effect("fails with a typed rate limit for provider error frames", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), + Effect.flip, ) - // Prefix the code so consumers see the failure mode, not just the - // sometimes-generic provider message. The bare message alone meant - // production errors like rate limits were indistinguishable from - // unrelated stream failures. - expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }]) + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" }) }), ) it.effect("falls back to error code when no message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" }) }), ) it.effect("falls back to error code when message is empty", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" }) }), ) @@ -1408,7 +1408,7 @@ describe("OpenAI Responses route", () => { // "OpenAI Responses response failed" string, hiding the real cause. it.effect("surfaces response.failed details from response.error", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1420,15 +1420,19 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }]) + expect(error.reason).toMatchObject({ + _tag: "ProviderInternal", + message: "server_error: Upstream model unavailable", + }) }), ) it.effect("surfaces response.failed code when no nested message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1437,9 +1441,10 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }]) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "invalid_prompt" }) }), ) @@ -1450,7 +1455,7 @@ describe("OpenAI Responses route", () => { // when they bubble up an HTTP error as an SSE `error` event. Honour // both shapes so the user still sees the underlying cause instead // of the catch-all string. - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1459,21 +1464,20 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "context_length_exceeded: prompt too long", - classification: "context-overflow", - }, - ]) + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "context_length_exceeded: prompt too long", + classification: "context-overflow", + }) }), ) it.effect("surfaces error event details nested under error", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1488,21 +1492,20 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "context_length_exceeded: prompt too long", - classification: "context-overflow", - }, - ]) + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "context_length_exceeded: prompt too long", + classification: "context-overflow", + }) }), ) it.effect("accepts nullable fields in spec-compliant error events", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1514,39 +1517,43 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }]) + expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" }) }), ) it.effect("falls back to a stable default when error is null", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" }) }), ) it.effect("falls back to a stable default when both error and response are absent", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" }) }), ) it.effect("falls back to a stable default when response.failed has no error payload", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }]) + expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" }) }), ) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 08370d53d4..bae8482a36 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -15,6 +15,7 @@ "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/*": "./src/v2/effect/*.ts", + "./v2/tui": "./src/v2/tui/index.ts", "./v2/tui/*": "./src/v2/tui/*.ts", "./v2": "./src/v2/promise/index.ts", "./v2/*": "./src/v2/promise/*.ts" diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index ce53b78bd3..8668570f8f 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -10,8 +10,14 @@ import type { Hooks, Transform } from "./registration.js" export interface Context { readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string + readonly progress: (update: Progress) => Effect.Effect +} + +export interface Progress { + readonly structured: Readonly> + readonly content?: ReadonlyArray } export type SchemaType = Schema.Codec @@ -253,8 +259,8 @@ export interface ToolExecuteBeforeEvent { readonly tool: string readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string input: unknown } @@ -262,8 +268,8 @@ export interface ToolExecuteAfterEvent { readonly tool: string readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string readonly input: unknown result: ToolResultValue output?: ToolOutput diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index 77d936f580..3f85aaae54 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -5,7 +5,9 @@ import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { JsonSchema, Schema } from "effect" import type { Hooks, Transform } from "./registration.js" -export type Context = Tool.Context +export type Context = Omit & { + readonly progress: (update: Tool.Progress) => Promise +} export type SchemaType = Tool.SchemaType export type Content = Tool.Content export type DynamicOutput = Tool.DynamicOutput @@ -50,8 +52,8 @@ export interface ToolExecuteBeforeEvent { readonly tool: string readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string input: unknown } @@ -59,8 +61,8 @@ export interface ToolExecuteAfterEvent { readonly tool: string readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string readonly input: unknown result: Tool.ToolExecuteAfterEvent["result"] output?: Tool.ToolExecuteAfterEvent["output"] diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index 5989bb34dc..fba5f934cc 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -19,6 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" +import type { Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" interface LocationCollection { @@ -86,27 +87,93 @@ export interface Data { } } -export interface RouteDefinition { +export type Route = + | { readonly type: "home" } + | { readonly type: "session"; readonly sessionID: string } + | { + readonly type: "plugin" + readonly id: string + readonly name: string + readonly data?: Record + } + +export type Destination = Route | Omit, "id"> + +export interface Page { readonly name: string - readonly render: (input: { readonly params: any }) => JSX.Element + readonly render: (input: { readonly data?: Record }) => JSX.Element } -export interface Route { - register(definition: RouteDefinition): () => void - navigate(input: { readonly name: string; readonly params?: any }): void - current(): { +export type Slot = (props: Record) => JSX.Element + +export interface KeymapCommand { + /** Stable command and config keybind identifier. Omit for an inline command. */ + readonly id?: string + /** Optional label used by command discovery and keyboard-help UI. */ + readonly title?: string + /** Optional longer description. */ + readonly description?: string + /** Groups the command in discovery and keyboard-help UI. */ + readonly group?: string + /** Enables or disables the command. */ + readonly enabled?: boolean | (() => boolean) + /** Configures automatic binding, or disables it for a named command. */ + readonly bind?: false | string + /** Adds a named command to the command palette. */ + readonly palette?: true + /** Adds a named command to prompt slash completion. */ + readonly slash?: { readonly name: string - readonly params: any + readonly aliases?: string[] + } + /** Executes the command. Return false to let keymap dispatch continue. */ + readonly run: () => void | false | Promise +} + +export interface KeymapLayer { + /** Limits the layer to one OpenCode input mode. Use global to opt out; defaults to base. */ + readonly mode?: string + /** Enables or disables the complete layer. */ + readonly enabled?: boolean | (() => boolean) + /** Limits the layer to a focused renderable. */ + readonly target?: () => Renderable | null | undefined + /** Resolves conflicts with other active layers. */ + readonly priority?: number + /** Commands owned by this layer. */ + readonly commands?: readonly KeymapCommand[] + /** IDs of commands whose configured bindings should be active in this layer. */ + readonly bindings?: readonly string[] +} + +export interface Keymap { + /** Creates a reactive keymap layer owned by the calling component. */ + layer(input: () => KeymapLayer): void + /** Dispatches a reachable command by ID. */ + dispatch(id: string): void + /** Returns the formatted shortcut for a registered command. */ + shortcut(id: string): string | undefined + /** Controls mutually exclusive OpenCode input modes. */ + readonly mode: { + /** Returns the active mode. */ + current(): string + /** Pushes a mode until the returned cleanup is called. */ + push(mode: string): () => void } } export interface UI { - readonly route: Route + readonly router: { + register(page: Page): () => void + navigate(destination: Destination): void + current(): Route + } + readonly slot: (name: string, render: Slot) => () => void } export interface Context { - readonly options: Record + readonly options: Readonly> readonly client: OpenCodeClient readonly data: Data + readonly keymap: Keymap readonly ui: UI } diff --git a/packages/plugin/src/v2/tui/index.ts b/packages/plugin/src/v2/tui/index.ts new file mode 100644 index 0000000000..8020216abc --- /dev/null +++ b/packages/plugin/src/v2/tui/index.ts @@ -0,0 +1 @@ +export * as Plugin from "./plugin.js" diff --git a/packages/plugin/src/v2/tui/plugin.ts b/packages/plugin/src/v2/tui/plugin.ts new file mode 100644 index 0000000000..2ab1e4faad --- /dev/null +++ b/packages/plugin/src/v2/tui/plugin.ts @@ -0,0 +1,14 @@ +import type { Context } from "./context.js" + +export type { Context } + +export type Cleanup = () => Promise | void + +export interface Definition { + readonly id: string + readonly setup: (context: Context) => Promise | Cleanup | void +} + +export function define(plugin: Definition) { + return plugin +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 79e97a22f7..243a7aa89b 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -11,6 +11,7 @@ import { Skill } from "@opencode-ai/schema/skill" const Plugin = await import("../src/v2/effect/index") const PromisePlugin = await import("../src/v2/promise/index") +const TuiPlugin = await import("../src/v2/tui/index") test.each([ ["effect", Plugin], @@ -38,3 +39,8 @@ test.each([ "Skill", ]) }) + +test("tui entrypoint exposes the V2 plugin definition", () => { + const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} }) + expect(plugin.id).toBe("demo") +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 99a58b2e47..9672bba540 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,12 +1,10 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { registerOpencodeSpinner } from "./component/register-spinner" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Deferred, Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { OpenCode } from "@opencode-ai/client" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationVersion } from "@opencode-ai/core/installation/version" import { ClipboardProvider, useClipboard } from "./context/clipboard" import { LogProvider, useLog, type LogSink } from "./context/log" import { ExitProvider, useExit } from "./context/exit" @@ -33,7 +31,13 @@ import { batch, Show, } from "solid-js" -import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" +import { + TuiLifecycleProvider, + TuiPathsProvider, + TuiStartupProvider, + TuiTerminalEnvironmentProvider, + useTuiStartup, +} from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" import { ErrorComponent } from "./component/error-component" @@ -72,22 +76,13 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" -import { createTuiApiAdapters } from "./plugin/adapters" -import { createTuiApi } from "./plugin/api" -import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" +import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime" +import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context" import { CommandPaletteDialog } from "./component/command-palette" -import { - COMMAND_PALETTE_COMMAND, - OPENCODE_BASE_MODE, - OpencodeKeymapProvider, - registerOpencodeKeymap, - useBindings, - useOpencodeKeymap, -} from "./keymap" +import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap" +import { Keymap } from "./context/keymap" import { DialogVariant } from "./component/dialog-variant" -import { createTuiAttention } from "./attention" -import * as TuiAudio from "./audio" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" @@ -149,7 +144,7 @@ export type TuiInput = { } args: Args config: Config.Interface - pluginHost: TuiPluginHost + packages: PackageResolver terminalHandoff?: () => Promise< | { readonly renderer: CliRenderer @@ -239,21 +234,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }), ) win32DisableProcessedInput() - const keymap = createDefaultOpenTuiKeymap(renderer) - yield* Effect.acquireRelease( - Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)), - (unregister) => Effect.sync(unregister), - ) + const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => Effect.promise(async () => { - try { - await input.pluginHost.dispose() - } catch (error) { - log("error", "Failed to dispose TUI plugins", { error }) - } + const results = await Promise.allSettled([...finalizers].reverse().map((finalizer) => finalizer())) + results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .forEach((result) => log("error", "Failed to dispose TUI resource", { error: result.reason })) }), ) - yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose)) const shutdown = yield* Deferred.make() const onSighup = () => destroyRenderer(renderer) yield* Effect.acquireRelease( @@ -291,55 +280,59 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { worktree: global.data + "/worktree", }} > - finalizers.delete(finalizer) + }, }} > - - - + + - - - - - - - + + + + + + + + @@ -349,17 +342,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + @@ -369,19 +363,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - - - - - + + + + + + + + - - - - + + + + @@ -406,14 +401,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App(props: { - pluginHost: TuiPluginHost - pair?: DialogPairCredentials -}) { +function App(props: { pair?: DialogPairCredentials }) { const log = useLog({ component: "app" }) const startup = useTuiStartup() - const configState = useConfig() - const config = configState.data + const config = useConfig() const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -430,7 +421,7 @@ function App(props: { const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() - const attention = createTuiAttention({ renderer, config, update: configState.update }) + const plugins = usePlugin() const clipboard = useClipboard() // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, @@ -461,39 +452,6 @@ function App(props: { } }) - const api = createTuiApi( - createTuiApiAdapters({ - version: InstallationVersion, - tuiConfig: config, - dialog, - keymap, - route, - routes: pluginRuntime.routes, - event, - client, - project, - data, - theme: themeState, - toast, - renderer, - attention, - Slot: pluginRuntime.Slot, - }), - ) - const [ready, setReady] = createSignal(false) - props.pluginHost - .start({ - api, - runtime: pluginRuntime, - dispose: () => attention.dispose(), - }) - .catch((error) => { - log.error("Failed to load TUI plugins", { error }) - }) - .finally(() => { - setReady(true) - }) - // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required. const offSelectionKeys = keymap.intercept( "key", @@ -505,7 +463,6 @@ function App(props: { ) onCleanup(() => { offSelectionKeys() - attention.dispose() }) // Wire up console copy-to-clipboard via opentui's onCopySelection callback @@ -519,11 +476,11 @@ function App(props: { renderer.clearSelection() } - const terminalTitleEnabled = () => config.terminal?.title ?? true - const pasteSummaryEnabled = () => config.prompt?.paste !== "full" + const terminalTitleEnabled = () => config.data.terminal?.title ?? true + const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" createEffect(() => { - renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse + renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.data.mouse }) // Update terminal window title based on current route and session @@ -548,7 +505,7 @@ function App(props: { } if (route.data.type === "plugin") { - renderer.setTerminalTitle(`OC | ${route.data.id}`) + renderer.setTerminalTitle(`OC | ${route.data.name}`) } }) @@ -631,8 +588,7 @@ function App(props: { title: "Switch session", category: "Session", suggested: data.session.list().length > 0, - slashName: "sessions", - slashAliases: ["resume", "continue"], + slash: { name: "sessions", aliases: ["resume", "continue"] }, run: () => { dialog.replace(() => ) }, @@ -642,8 +598,7 @@ function App(props: { title: "New session", suggested: route.data.type === "session", category: "Session", - slashName: "new", - slashAliases: ["clear"], + slash: { name: "new", aliases: ["clear"] }, run: () => { route.navigate({ type: "home", @@ -665,9 +620,8 @@ function App(props: { title: "Switch model", suggested: true, category: "Agent", - slashName: "models", // Bias /mo toward /models over /move without changing global fuzzy scoring. - slashAliases: ["mo"], + slash: { name: "models", aliases: ["mo"] }, run: () => { dialog.replace(() => ) }, @@ -712,7 +666,7 @@ function App(props: { name: "agent.list", title: "Switch agent", category: "Agent", - slashName: "agents", + slash: { name: "agents" }, run: () => { dialog.replace(() => ) }, @@ -721,7 +675,7 @@ function App(props: { name: "mcp.list", title: "MCP servers", category: "Agent", - slashName: "mcps", + slash: { name: "mcps" }, run: () => { dialog.replace(() => ) }, @@ -748,7 +702,7 @@ function App(props: { title: "Switch model variant", category: "Agent", hidden: local.model.variant.list().length === 0, - slashName: "variants", + slash: { name: "variants" }, run: () => { if (local.model.variant.list().length === 0) { return toast.show({ @@ -773,7 +727,7 @@ function App(props: { name: "provider.connect", title: "Connect integration", suggested: !connected(), - slashName: "connect", + slash: { name: "connect" }, run: () => { dialog.replace(() => ( { dialog.replace(() => ) }, @@ -795,7 +749,7 @@ function App(props: { { name: "opencode.status", title: "View status", - slashName: "status", + slash: { name: "status" }, run: () => { dialog.replace(() => ) }, @@ -804,7 +758,7 @@ function App(props: { { name: "server.pair", title: "Pair device", - slashName: "pair", + slash: { name: "pair" }, run: () => { dialog.replace(() => ) }, @@ -815,7 +769,7 @@ function App(props: { { name: "server.reload", title: "Reload server", - slashName: "reload", + slash: { name: "reload" }, run: async () => { dialog.clear() toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) @@ -832,7 +786,7 @@ function App(props: { { name: "opencode.debug", title: "View debug info", - slashName: "debug", + slash: { name: "debug" }, run: () => { dialog.replace(() => ) }, @@ -841,7 +795,7 @@ function App(props: { { name: "theme.switch", title: "Switch theme", - slashName: "themes", + slash: { name: "themes" }, run: () => { dialog.replace(() => ) }, @@ -871,7 +825,7 @@ function App(props: { { name: "help.show", title: "Help", - slashName: "help", + slash: { name: "help" }, run: () => { dialog.replace(() => ) }, @@ -889,8 +843,7 @@ function App(props: { { name: "app.exit", title: "Exit the app", - slashName: "exit", - slashAliases: ["quit", "q"], + slash: { name: "exit", aliases: ["quit", "q"] }, run: () => exit(), category: "System", }, @@ -932,7 +885,7 @@ function App(props: { run: () => { const next = !terminalTitleEnabled() if (!next) renderer.setTerminalTitle("") - void configState + void config .update((draft) => { draft.terminal = { ...draft.terminal, title: next } }) @@ -942,13 +895,13 @@ function App(props: { }, { name: "app.toggle.animations", - title: (config.animations ?? true) ? "Disable animations" : "Enable animations", + title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations", category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { - draft.animations = !(config.animations ?? true) + draft.animations = !(config.data.animations ?? true) }) .catch(toast.error) dialog.clear() @@ -956,13 +909,13 @@ function App(props: { }, { name: "app.toggle.file_context", - title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context", + title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context", category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { - draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) } + draft.prompt = { ...draft.prompt, editor: !(config.data.prompt?.editor ?? true) } }) .catch(toast.error) dialog.clear() @@ -970,13 +923,16 @@ function App(props: { }, { name: "app.toggle.diffwrap", - title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { - draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" } + draft.diffs = { + ...draft.diffs, + wrap: (config.data.diffs?.wrap ?? "word") === "word" ? "none" : "word", + } }) .catch(toast.error) dialog.clear() @@ -988,7 +944,7 @@ function App(props: { category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" } }) @@ -1018,11 +974,11 @@ function App(props: { useBindings(() => ({ mode: OPENCODE_BASE_MODE, - bindings: config.keybinds.gather("app", appBindingCommands), + bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)), })) useBindings(() => ({ - bindings: config.keybinds.gather("app.global", appGlobalBindingCommands), + bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)), })) useBindings(() => ({ @@ -1032,7 +988,7 @@ function App(props: { if (!current?.focused) return true return current.current.text === "" }, - bindings: config.keybinds.gather("app_exit", ["app.exit"]), + bindings: config.data.keybinds.get("app.exit"), })) event.on("tui.command.execute", (evt, { workspace }) => { @@ -1087,14 +1043,6 @@ function App(props: { }) }) - const plugin = createMemo(() => { - if (!ready()) return - if (route.data.type !== "plugin") return - const render = pluginRuntime.routes.get(route.data.id) - if (!render) return route.navigate({ type: "home" })} /> - return render({ params: route.data.data }) - }) - // Suppress the full-screen overlay for transient startup and event-stream retry states. // Initial connection gets a longer grace period; retries surface more quickly. const [showReconnecting, setShowReconnecting] = createSignal(false) @@ -1144,7 +1092,7 @@ function App(props: { - + @@ -1155,16 +1103,22 @@ function App(props: { {(_) => } + + ( + route.navigate({ type: "home" })} /> + )} + /> + - {plugin()} - + - + - + diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 7e964d3437..64df044591 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -281,16 +281,16 @@ export function DialogConfig() { footerHints={[{ title: "←/→", label: "change" }]} bindings={[ { - key: "left", - desc: "Previous value", + bind: "left", + title: "Previous value", group: "Settings", - cmd: () => void change(-1), + run: () => void change(-1), }, { - key: "right", - desc: "Next value", + bind: "right", + title: "Next value", group: "Settings", - cmd: () => void change(1), + run: () => void change(1), }, ]} /> diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx index 21554aac95..2f88617f02 100644 --- a/packages/tui/src/component/dialog-debug.tsx +++ b/packages/tui/src/component/dialog-debug.tsx @@ -1,13 +1,13 @@ import { TextAttributes } from "@opentui/core" import { createMemo, createSignal, For } from "solid-js" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { useRoute } from "../context/route" import { useLocal } from "../context/local" import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" -import { useBindings } from "../keymap" import { describeOS, describeTerminal } from "../util/system" export function DialogDebug() { @@ -46,8 +46,9 @@ export function DialogDebug() { .catch(toast.error) } - useBindings(() => ({ - bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }], + Keymap.createLayer(() => ({ + mode: "modal", + commands: [{ bind: "return", title: "Copy debug info", group: "Dialog", run: copy }], })) return ( diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 74aa76040b..58b18e16f4 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -9,8 +9,8 @@ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { useBindings } from "../keymap" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" @@ -90,6 +90,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected No integrations available } + noMatchView={ + + No integrations found + + } /> ) } @@ -183,8 +188,8 @@ function KeyMethod(props: { placeholder="API key" onConfirm={(key) => { if (!key) return - void client.api.integration - .connect.key({ + void client.api.integration.connect + .key({ integrationID: props.integration.id, location: location(data), key, @@ -222,8 +227,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void client.api.integration - .connect.oauth({ + void client.api.integration.connect + .oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -273,13 +278,14 @@ function OAuthAuto(props: { let timer: ReturnType | undefined let settled = false - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "c", - desc: "Copy authorization details", + bind: "c", + title: "Copy authorization details", group: "Dialog", - cmd: () => { + run: () => { const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url clipboard .write?.(value) @@ -291,8 +297,8 @@ function OAuthAuto(props: { })) const poll = () => { - void client.api.integration - .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -357,8 +363,8 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void client.api.integration - .attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void client.api.integration.attempt + .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index fc21b2b610..4f9a4d83d6 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,5 +1,6 @@ import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { useData } from "../context/data" +import { Keymap } from "../context/keymap" import { pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" @@ -11,7 +12,6 @@ import { useToast } from "../ui/toast" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { getScrollAcceleration } from "../util/scroll" -import { useBindings } from "../keymap" // Sort by how much attention a server needs: auth prompts first, then failures, // then healthy servers, and intentionally-off servers last. @@ -134,8 +134,9 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { .catch(toast.error) } - useBindings(() => ({ - bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }], + Keymap.createLayer(() => ({ + mode: "modal", + commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }], })) useKeyboard((event) => { diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index bc30045b40..6ff94d690c 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -5,6 +5,7 @@ import path from "path" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useClient } from "../context/client" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useData } from "../context/data" import { abbreviateHome } from "../runtime" @@ -13,7 +14,6 @@ import { Locale } from "../util/locale" import { errorMessage } from "../util/error" import { isRecord } from "../util/record" import { useToast } from "../ui/toast" -import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" @@ -21,7 +21,9 @@ import type { ProjectDirectoriesOutput } from "@opencode-ai/client" import { useRoute } from "../context/route" import { DialogProjectCopyName } from "./dialog-project-copy-name" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } +export type MoveSessionSelection = + | { type: "directory"; directory: string; subdirectory: boolean } + | { type: "new"; name: string } type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { @@ -43,12 +45,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const route = useRoute() const toast = useToast() const paths = useTuiPaths() + const shortcuts = Keymap.useShortcuts() const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) const [replacementCurrent, setReplacementCurrent] = createSignal() const [loadError, setLoadError] = createSignal() - const deleteHint = useCommandShortcut("dialog.move_session.delete") onMount(() => dialog.setSize("xlarge")) function reopen(initialRemoving?: string) { @@ -120,7 +122,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (showError()) return [] const data = directoryData() const current = currentRoot()?.directory - if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }] + if (directories.loading && !data && !current) return [] const roots = [...(data ?? [])] if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) roots.sort((a, b) => { @@ -130,13 +132,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length return 0 }) - if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] + if (roots.length === 0) return [] const subdirectories = sessionData.session .list() .filter( - (session) => - session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), + (session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), ) .map((session) => session.location.directory) .filter((directory) => !roots.some((root) => root.directory === directory)) @@ -174,7 +175,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { titleView: isRemoving ? ( Deleting {item.location} ) : deleting ? ( - Press {deleteHint()} again to confirm + Press {shortcuts.get("dialog.move_session.delete")} again to confirm ) : suffix ? ( <> {visible.slice(0, split)} @@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { options={options()} emptyView={ showError() ? ( - + Could not load project directories {errorMessage(loadError())} + Close and reopen Move session to try again. - ) : undefined + ) : directories.loading || loadedProject.loading ? ( + + Loading project directories… + + ) : ( + + No project directories available + + ) + } + noMatchView={ + + No project directories found + } locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} current={current()} diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index d1b5b10787..8f77bff7a1 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -25,12 +25,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { dialog.setCentered(true) const [server] = createResource(() => - client.api.server - .get() - .catch((error) => { - setLoadError(error) - return undefined - }), + client.api.server.get().catch((error) => { + setLoadError(error) + return undefined + }), ) const info = createMemo(() => { const current = server() @@ -46,11 +44,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { const value = info() if (!value) return return ( - + URLs @@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { {showPassword() ? value.password : "************"} - ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))} - > + ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}> Run `opencode service set hostname 0.0.0.0` to access the service remotely. @@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { esc - - {(error) => {errorMessage(error())}} - - Loading server information...}> - = 36} - fallback={ - Loading server information…}> + = 36} + fallback={ + + {content()} + + } > {content()} - - } - > - {content()} - + + + } + > + {(error) => ( + + + Could not load server information + + {errorMessage(error())} + Close and reopen Pair to try again. + + )} ) diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx index 3ae331da59..98bb711f7b 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -1,16 +1,14 @@ import { InputRenderable, TextAttributes } from "@opentui/core" import { Slug } from "@opencode-ai/core/util/slug" import { createSignal, onMount } from "solid-js" -import { useConfig } from "../config" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { useBindings, useCommandShortcut } from "../keymap" import { useDialog, type DialogContext } from "../ui/dialog" export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { const dialog = useDialog() const { theme } = useTheme() - const config = useConfig().data - const generateShortcut = useCommandShortcut("dialog.project_copy.generate") + const shortcuts = Keymap.useShortcuts() const [inputTarget, setInputTarget] = createSignal() let input: InputRenderable @@ -23,19 +21,19 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void props.onConfirm(slugify(input.value) || Slug.create()) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", target: inputTarget, enabled: inputTarget() !== undefined, priority: 1, commands: [ { - name: "dialog.project_copy.generate", + id: "dialog.project_copy.generate", title: "Generate project copy name", - category: "Dialog", + group: "Dialog", run: generate, }, ], - bindings: config.keybinds.get("dialog.project_copy.generate"), })) onMount(() => { @@ -73,7 +71,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void enter submit - {generateShortcut()} generate one + {shortcuts.get("dialog.project_copy.generate")} generate one @@ -82,7 +80,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void DialogProjectCopyName.show = (dialog: DialogContext) => new Promise((resolve) => { - dialog.replace(() => , () => resolve(null)) + dialog.replace( + () => , + () => resolve(null), + ) }) function slugify(input: string) { diff --git a/packages/tui/src/component/dialog-retry-action.tsx b/packages/tui/src/component/dialog-retry-action.tsx index b52a6e9b9f..25befc571a 100644 --- a/packages/tui/src/component/dialog-retry-action.tsx +++ b/packages/tui/src/component/dialog-retry-action.tsx @@ -1,11 +1,11 @@ import { RGBA, TextAttributes } from "@opentui/core" import open from "open" import { createSignal } from "solid-js" +import { Keymap } from "../context/keymap" import { selectedForeground, useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" import { Link } from "../ui/link" import { BgPulse } from "./bg-pulse" -import { useBindings } from "../keymap" const GO_URL = "https://opencode.ai/go" const PAD_X = 3 @@ -44,31 +44,32 @@ export function DialogRetryAction(props: DialogRetryActionProps) { const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "left", - desc: "Previous retry option", + bind: "left", + title: "Previous retry option", group: "Dialog", - cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), + run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { - key: "right", - desc: "Next retry option", + bind: "right", + title: "Next retry option", group: "Dialog", - cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), + run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { - key: "tab", - desc: "Next retry option", + bind: "tab", + title: "Next retry option", group: "Dialog", - cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), + run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { - key: "return", - desc: "Confirm retry option", + bind: "return", + title: "Confirm retry option", group: "Dialog", - cmd: () => { + run: () => { if (selected() === "action") runAction(props, dialog) else dismiss(props, dialog) }, diff --git a/packages/tui/src/component/dialog-session-delete-failed.tsx b/packages/tui/src/component/dialog-session-delete-failed.tsx index f3617a5347..8754fd03db 100644 --- a/packages/tui/src/component/dialog-session-delete-failed.tsx +++ b/packages/tui/src/component/dialog-session-delete-failed.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useBindings } from "../keymap" export function DialogSessionDeleteFailed(props: { session: string @@ -40,13 +40,24 @@ export function DialogSessionDeleteFailed(props: { if (!props.onDone) dialog.clear() } - useBindings(() => ({ - bindings: [ - { key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() }, - { key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, - { key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, - { key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, - { key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() }, + { bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") }, + { bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") }, + { + bind: "right", + title: "Restore broken session", + group: "Dialog", + run: () => setStore("active", "restore"), + }, + { + bind: "down", + title: "Restore broken session", + group: "Dialog", + run: () => setStore("active", "restore"), + }, ], })) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 07830e4202..c7eb69ce37 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -5,6 +5,7 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" import { useData } from "../context/data" +import { Keymap } from "../context/keymap" import { Locale } from "../util/locale" import { useProject } from "../context/project" import { useTheme } from "../context/theme" @@ -12,7 +13,6 @@ import { useClient } from "../context/client" import { useLocal } from "../context/local" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { useCommandShortcut } from "../keymap" import { DialogSessionRename } from "./dialog-session-rename" import { Spinner } from "./spinner" import { errorMessage } from "../util/error" @@ -26,11 +26,10 @@ export function DialogSessionList() { const client = useClient() const local = useLocal() const toast = useToast() + const [filter, setFilter] = createSignal("") + const shortcuts = Keymap.useShortcuts() const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() - const quickSwitch1 = useCommandShortcut("session.quick_switch.1") - const quickSwitch9 = useCommandShortcut("session.quick_switch.9") - const deleteHint = useCommandShortcut("session.delete") const [searchResults] = createResource(search, async (query) => { if (!query) return @@ -44,26 +43,43 @@ export function DialogSessionList() { directory: location.directory, workspace: location.workspaceID, }) - return { query, sessions: response.data } + return { query, sessions: response.data, error: undefined } } catch (error) { // A transient transport failure must degrade search, not crash the TUI // through the root ErrorBoundary when the errored resource is read. - toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) - return { query, sessions: [] as SessionInfo[] } + return { query, sessions: [] as SessionInfo[], error } } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const localSessions = createMemo(() => { + const query = filter().trim().toLowerCase() + const sessions = data.session.list() + if (!query) return sessions + return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query)) + }) const sessions = createMemo(() => { - const query = search() - if (!query) return data.session.list() + const query = filter() + const local = localSessions() + if (!query) return local + if (query !== search() || searchResults.loading) return local const result = searchResults() - return result?.query === query ? result.sessions : [] + if (result?.query !== query || result.error) return local + return result.sessions + }) + const searchState = createMemo(() => { + const query = filter() + if (!query) return { message: "No sessions available", error: false } + if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false } + const result = searchResults() + if (result?.query === query && result.error) + return { message: "Could not search sessions. Change the search to try again.", error: true } + return { message: "No sessions found", error: false } }) const quickSwitchHint = createMemo(() => { - const first = quickSwitch1() - const last = quickSwitch9() + const first = shortcuts.get("session.quick_switch.1") + const last = shortcuts.get("session.quick_switch.9") if (!first || !last) return return quickSwitchRange(first, last) }) @@ -89,7 +105,7 @@ export function DialogSessionList() { const slot = slotByID.get(session.id) const deleting = toDelete() === session.id return { - title: deleting ? `Press ${deleteHint()} again to confirm` : session.title, + title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title, value: session.id, category, footer, @@ -120,7 +136,20 @@ export function DialogSessionList() { options={options()} skipFilter={true} current={currentSessionID()} - onFilter={setSearch} + onFilter={(query) => { + setFilter(query) + setSearch(query) + }} + emptyView={ + + No sessions available + + } + noMatchView={ + + {searchState().message} + + } onMove={() => setToDelete(undefined)} onSelect={(option) => { route.navigate({ type: "session", sessionID: option.value }) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index d49d054c91..3826a5a3c5 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { createResource, createMemo, createSignal } from "solid-js" +import { createResource, createMemo, createSignal, Match, Switch } from "solid-js" import { useDialog } from "../ui/dialog" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" @@ -57,17 +57,36 @@ export function DialogSkill(props: DialogSkillProps) { - - Could not load skills - - {errorMessage(loadError())} - - ) : undefined + + No skills available + + } + > + + + + Could not load skills + + {errorMessage(loadError())} + Close and reopen Skills to try again. + + + + + Loading skills… + + + + } + noMatchView={ + + No skills found + } /> ) diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index b08886f79f..cefe315ee3 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -2,9 +2,9 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "../util/locale" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { usePromptStash, type StashEntry } from "./prompt/stash" -import { useCommandShortcut } from "../keymap" function getRelativeTime(timestamp: number): string { const now = Date.now() @@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() + const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() - const deleteHint = useCommandShortcut("stash.delete") const options = createMemo(() => { const entries = stash.list() @@ -42,7 +42,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text), + title: isDeleting + ? `Press ${shortcuts.get("stash.delete")} again to confirm` + : getStashPreview(entry.prompt.text), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), diff --git a/packages/tui/src/component/plugin-route-missing.tsx b/packages/tui/src/component/plugin-route-missing.tsx index 77e2ea8dd3..98fb3efb3b 100644 --- a/packages/tui/src/component/plugin-route-missing.tsx +++ b/packages/tui/src/component/plugin-route-missing.tsx @@ -1,11 +1,13 @@ import { useTheme } from "../context/theme" -export function PluginRouteMissing(props: { id: string; onHome: () => void }) { +export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) { const { theme } = useTheme() return ( - Unknown plugin route: {props.id} + + Unknown plugin route: {props.id}/{props.name} + go home diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 03e1b5fcdb..5b14926896 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -19,7 +19,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" -import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" +import { useBindings, useCommandSlashes } from "../../keymap" +import { Keymap } from "../../context/keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/client" @@ -88,7 +89,7 @@ export function Autocomplete(props: { const data = useData() const project = useProject() const slashes = useCommandSlashes() - const modeStack = useOpencodeModeStack() + const keymap = Keymap.use() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -106,7 +107,7 @@ export function Autocomplete(props: { createEffect(() => { if (!store.visible) return - const popMode = modeStack.push("autocomplete") + const popMode = keymap.mode.push("autocomplete") onCleanup(popMode) }) @@ -309,10 +310,10 @@ export function Autocomplete(props: { } const [files] = createResource( - () => ({ query: search(), location: location() }), + () => ({ query: search(), location: location(), visible: store.visible }), async (input) => { - if (!store.visible || store.visible === "/") return [] - if (referenceMatch()) return [] + if (!input.visible || input.visible === "/") return { options: [], failed: false } + if (referenceMatch()) return { options: [], failed: false } const { lineRange, baseQuery } = extractLineRange(input.query ?? "") const result = await client.api.file @@ -324,34 +325,37 @@ export function Autocomplete(props: { workspace: input.location?.workspaceID ?? project.workspace.current(), }, }) - .catch(() => undefined) + .then( + (result) => result, + () => undefined, + ) + + if (!result) return { options: [], failed: true } const options: AutocompleteOption[] = [] // Add file options. Trust the order returned by fff (frecency, fuzzy // score, filename bonus, etc. are already factored in). - if (result) { - const width = props.anchor().width - 4 - options.push( - ...result.data.map((item): AutocompleteOption => { - const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) - return { - display: Locale.truncateMiddle(filename, width), - value: filename, - isDirectory: item.type === "directory", - path: item.path, - onSelect: () => { - insertPart(filename, part) - }, - } - }), - ) - } + const width = props.anchor().width - 4 + options.push( + ...result.data.map((item): AutocompleteOption => { + const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) + return { + display: Locale.truncateMiddle(filename, width), + value: filename, + isDirectory: item.type === "directory", + path: item.path, + onSelect: () => { + insertPart(filename, part) + }, + } + }), + ) - return options + return { options, failed: false } }, { - initialValue: [], + initialValue: { options: [], failed: false }, }, ) @@ -470,8 +474,8 @@ export function Autocomplete(props: { })) }) - const options = createMemo((prev: AutocompleteOption[] | undefined) => { - const filesValue = files() + const options = createMemo(() => { + const fileSearch = files() const referenceMatchValue = referenceMatch() const agentsValue = agents() const referenceAliasesValue = referenceAliases() @@ -484,7 +488,7 @@ export function Autocomplete(props: { // Files come from fff already fuzzy ranked and filtered // it shouldn't be additionally sorted by fuzzysort as it will loose the results - const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] + const fileOptions: AutocompleteOption[] = store.visible === "@" && !files.loading ? fileSearch.options : [] const nonFileOptions: AutocompleteOption[] = store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] @@ -492,10 +496,6 @@ export function Autocomplete(props: { return [...nonFileOptions, ...fileOptions] } - if (files.loading && prev && prev.length > 0) { - return prev - } - const fuzziedNonFiles = fuzzysort .go(removeLineRange(searchValue), nonFileOptions, { keys: [ @@ -628,13 +628,13 @@ export function Autocomplete(props: { }, }, ], - bindings: config.keybinds.gather("prompt.autocomplete", [ + bindings: [ "prompt.autocomplete.prev", "prompt.autocomplete.next", "prompt.autocomplete.hide", "prompt.autocomplete.select", "prompt.autocomplete.complete", - ]), + ].flatMap((command) => config.keybinds.get(command)), })) function show(mode: "@" | "/") { @@ -715,6 +715,13 @@ export function Autocomplete(props: { let scroll: ScrollBoxRenderable const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) + const emptyMessage = createMemo(() => { + if (store.visible === "/") return "No matching commands" + if (files.loading) return "Searching…" + if (files().failed) return "Could not search files. Keep typing to try again." + return "No matching files, agents, or references" + }) + const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed) return ( - No matching items + {emptyMessage()} } > diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 94ecbb4654..7d8dec1379 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -450,7 +450,7 @@ export function Prompt(props: PromptProps) { title: "Open editor", category: "Session", name: "prompt.editor", - slashName: "editor", + slash: { name: "editor" }, run: async () => { dialog.clear() @@ -498,7 +498,7 @@ export function Prompt(props: PromptProps) { title: "Skills", name: "prompt.skills", category: "Prompt", - slashName: "skills", + slash: { name: "skills" }, run: () => { dialog.replace(() => ( { move.open() }, @@ -537,7 +537,7 @@ export function Prompt(props: PromptProps) { useBindings(() => ({ mode: OPENCODE_BASE_MODE, - bindings: config.keybinds.gather("prompt.palette", [ + bindings: [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", @@ -548,7 +548,7 @@ export function Prompt(props: PromptProps) { "session.interrupt", "session.background", "session.move", - ]), + ].flatMap((command) => config.keybinds.get(command)), })) const ref: PromptRef = { @@ -1188,10 +1188,7 @@ export function Prompt(props: PromptProps) { } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - config.prompt?.paste !== "full" - ) { + if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return } @@ -1298,10 +1295,7 @@ export function Prompt(props: PromptProps) { }) const spinnerDef = createMemo(() => { - const agent = - status() === "running" - ? local.agent.current() - : local.agent.current() + const agent = status() === "running" ? local.agent.current() : local.agent.current() const color = agent ? local.agent.color(agent.id) : theme.border return { frames: createFrames({ diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ea75ef0699..7025effc16 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -7,6 +7,7 @@ import { createStore, reconcile } from "solid-js/store" import { TuiKeybind } from "./keybind" export interface Interface { + readonly path?: string readonly get: () => Promise readonly update: (update: (draft: any) => void) => Promise } @@ -71,12 +72,9 @@ export const Info = Schema.Struct({ Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)), ).annotate({ description: "Attention sound volume from 0 to 1" }), sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }), - sounds: Schema.optional( - Schema.Record( - AttentionSoundName, - Schema.optionalKey(Schema.String), - ), - ).annotate({ description: "Sound file overrides by attention event" }), + sounds: Schema.optional(Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))).annotate({ + description: "Sound file overrides by attention event", + }), }), ).annotate({ description: "System notification and sound settings" }), diffs: Schema.optional( @@ -181,6 +179,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res const ConfigContext = createContext<{ data: Resolved + path?: string update: Interface["update"] }>() @@ -199,7 +198,7 @@ export function ConfigProvider(props: { return info } return ( - {props.children} + {props.children} ) } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 66ca56fcc9..2506023fbe 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -27,7 +27,7 @@ import type { SkillInfo, OpenCodeEvent, } from "@opencode-ai/client" -import type { Data } from "@opencode-ai/plugin/v2/tui/context" +import type { Plugin } from "@opencode-ai/plugin/v2/tui" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" @@ -404,6 +404,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.instructions.updated": + const instructions = event.metadata?.instructions + if ( + typeof instructions === "object" && + instructions !== null && + "initial" in instructions && + instructions.initial === true + ) + break message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), @@ -841,7 +849,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": - if (bootstrapping) break void result.location.mcp.server.refresh(event.location) break case "mcp.resources.changed": @@ -1044,7 +1051,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server }, async refresh(ref?: LocationRef) { - const result = await client.api.mcp.list({ location: locationQuery(ref) }) + const result = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], @@ -1057,7 +1064,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource }, async refresh(ref?: LocationRef) { - const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref) }) + const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], @@ -1108,7 +1115,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, } - result satisfies Data + result satisfies Plugin.Context["data"] async function bootstrap() { if (bootstrapping) return bootstrapping diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx new file mode 100644 index 0000000000..16fdb9c8c5 --- /dev/null +++ b/packages/tui/src/context/keymap.tsx @@ -0,0 +1,332 @@ +import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context" +import { InputRenderable, TextareaRenderable } from "@opentui/core" +import { stringifyKeyStroke } from "@opentui/keymap" +import { + registerBackspacePopsPendingSequence, + registerBaseLayoutFallback, + registerCommaBindings, + registerEscapeClearsPendingSequence, + registerManagedTextareaLayer, + registerTimedLeader, +} from "@opentui/keymap/addons/opentui" +import { formatKeySequence } from "@opentui/keymap/extras" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid" +import { useRenderer } from "@opentui/solid" +import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" +import { useConfig } from "../config" +import { TuiKeybind } from "../config/keybind" + +declare module "@opentui/keymap" { + interface Command { + slash?: { + name: string + aliases?: string[] + } + } +} + +const MODE = { key: "opencode.mode", base: "base" } as const + +type OpenTuiKeymap = Parameters[0]["keymap"] +type Mode = ReturnType + +const Context = createContext<{ readonly keymap: OpenTuiKeymap; readonly mode: Mode }>() + +function Provider(props: ParentProps) { + const renderer = useRenderer() + const config = useConfig() + const keymap = createDefaultOpenTuiKeymap(renderer) + const mode = createMode(keymap) + const dispose = [ + registerCommaBindings(keymap), + keymap.appendBindingExpander((context) => { + const key = Object.entries({ enter: "return", esc: "escape", pgdown: "pagedown", pgup: "pageup" }).reduce( + (result, [alias, value]) => + result.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${value}`), + context.input, + ) + if (key === context.input) return + return [{ key, displays: context.displays }] + }), + registerBaseLayoutFallback(keymap), + registerEscapeClearsPendingSequence(keymap), + registerBackspacePopsPendingSequence(keymap), + registerManagedTextareaLayer(keymap, renderer, { + enabled: () => { + const editor = renderer.currentFocusedEditor + return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable) + }, + bindings: [ + "input.move.left", + "input.move.right", + "input.move.up", + "input.move.down", + "input.select.left", + "input.select.right", + "input.select.up", + "input.select.down", + "input.line.home", + "input.line.end", + "input.select.line.home", + "input.select.line.end", + "input.visual.line.home", + "input.visual.line.end", + "input.select.visual.line.home", + "input.select.visual.line.end", + "input.buffer.home", + "input.buffer.end", + "input.select.buffer.home", + "input.select.buffer.end", + "input.delete.line", + "input.delete.to.line.end", + "input.delete.to.line.start", + "input.backspace", + "input.delete", + "input.newline", + "input.undo", + "input.redo", + "input.word.forward", + "input.word.backward", + "input.select.word.forward", + "input.select.word.backward", + "input.delete.word.forward", + "input.delete.word.backward", + "input.select.all", + "input.submit", + ].flatMap((command) => config.data.keybinds.get(command)), + }), + ] + const leader = config.data.keybinds.get("leader")?.[0]?.key + if (leader) { + dispose.push( + registerTimedLeader(keymap, { + trigger: leader, + name: "leader", + timeoutMs: config.data.leader.timeout, + }), + ) + } + onCleanup(() => { + dispose.reverse().forEach((item) => item()) + mode.dispose() + }) + return ( + + {props.children} + + ) +} + +export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context" + +export interface Keymap { + /** Dispatches a reachable command by ID. */ + dispatch(id: string): void + /** Controls mutually exclusive OpenCode input modes. */ + readonly mode: { + /** Returns the active mode. */ + current(): string + /** Pushes a mode until the returned cleanup is called. */ + push(mode: string): () => void + } +} + +function use(): Keymap { + const value = useValue() + return { + dispatch(id) { + value.keymap.dispatchCommand(id) + }, + mode: value.mode, + } +} + +function createLayer(input: () => KeymapLayer) { + useValue() + const config = useConfig() + useBindings(() => { + const layer = input() + const { commands, bindings, mode, ...options } = layer + const grouped = (commands ?? []).reduce( + (result, command) => { + if (command.id !== undefined) { + if (!command.id) throw new Error("Keymap command IDs cannot be empty") + if (typeof command.bind === "string" && !command.bind) + throw new Error("Keymap command bindings cannot be empty") + result.named.push({ ...command, id: command.id }) + return result + } + if (command.palette) throw new Error("Palette commands require an ID") + if (command.slash) throw new Error("Slash commands require an ID") + if (typeof command.bind !== "string") throw new Error("Inline keymap commands require bind") + if (!command.bind) throw new Error("Keymap command bindings cannot be empty") + result.inline.push({ ...command, id: undefined, bind: command.bind }) + return result + }, + { + named: [] as Array, + inline: [] as Array, + }, + ) + return { + ...options, + ...(mode === "global" ? {} : { mode: mode ?? MODE.base }), + commands: grouped.named.map((command) => { + const { id, description, group, palette, bind, ...definition } = command + return { + ...definition, + name: id, + ...(description === undefined ? {} : { desc: description }), + ...(group === undefined ? {} : { category: group }), + ...(palette === undefined ? {} : { namespace: "palette" }), + } + }), + bindings: [ + ...grouped.inline.map((command) => ({ + key: command.bind, + cmd: () => { + if (command.enabled === false) return false + if (typeof command.enabled === "function" && !command.enabled()) return false + return command.run() + }, + ...(command.title === undefined && command.description === undefined + ? {} + : { desc: command.title ?? command.description }), + ...(command.group === undefined ? {} : { group: command.group }), + })), + ...grouped.named.flatMap((command) => { + if (command.bind === false) return [] + const configured = config.data.keybinds.get(command.id) + if (configured.length) return configured + if (typeof command.bind !== "string") return [] + return [{ key: command.bind, cmd: command.id }] + }), + ...(bindings ?? []).flatMap((id) => config.data.keybinds.get(id)), + ], + } + }) +} + +function useShortcuts() { + useValue() + const config = useConfig() + const shortcuts = useKeymapSelector((keymap) => { + const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) + const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) + return new Map( + commands.map((id) => [id, formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data))]), + ) + }) + return { + get(id: string) { + return shortcuts().get(id) + }, + } +} + +function useCommands(): Accessor { + const value = useValue() + return useKeymapSelector((keymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + }) + .map((entry) => ({ + id: entry.command.name, + title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + group: typeof entry.command.category === "string" ? entry.command.category : undefined, + palette: entry.command.namespace === "palette" ? true : undefined, + slash: entry.command.slash, + run: () => { + value.keymap.dispatchCommand(entry.command.name) + }, + })), + ) +} + +function usePendingSequence() { + useValue() + return useKeymapSelector((keymap) => keymap.getPendingSequence()) +} + +function useActiveKeys() { + useValue() + return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) +} + +function useValue() { + const value = useContext(Context) + if (!value) throw new Error("Keymap.Provider is missing") + return value +} + +export const Keymap = { + Provider, + use, + createLayer, + useShortcuts, + useCommands, + usePendingSequence, + useActiveKeys, +} as const + +function createMode(keymap: OpenTuiKeymap) { + keymap.setData(MODE.key, MODE.base) + const unregister = keymap.registerLayerFields({ + mode(value, context) { + context.require(MODE.key, value) + }, + }) + const stack: { readonly id: symbol; readonly mode: string }[] = [] + let disposed = false + + const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base) + + return { + current() { + return stack.at(-1)?.mode ?? MODE.base + }, + push(mode: string) { + if (disposed) return () => {} + const id = Symbol(mode) + stack.push({ id, mode }) + update() + return () => { + const index = stack.findIndex((item) => item.id === id) + if (index < 0) return + stack.splice(index, 1) + update() + } + }, + dispose() { + if (disposed) return + disposed = true + stack.length = 0 + unregister() + keymap.setData(MODE.key, undefined) + }, + } +} + +function formatOptions(config: ReturnType["data"]) { + const leader = config.keybinds.get("leader")?.[0]?.key + return { + tokenDisplay: { + leader: leader ? (typeof leader === "string" ? leader : stringifyKeyStroke(leader)) : TuiKeybind.LeaderDefault, + }, + keyNameAliases: { + up: "↑", + down: "↓", + left: "←", + right: "→", + pageup: "pgup", + pagedown: "pgdn", + delete: "del", + }, + modifierAliases: { + meta: "alt", + }, + } as const +} diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx index 7355fe54d7..fa8a5a39c1 100644 --- a/packages/tui/src/context/route.tsx +++ b/packages/tui/src/context/route.tsx @@ -17,6 +17,7 @@ export type SessionRoute = { export type PluginRoute = { type: "plugin" id: string + name: string data?: Record } @@ -47,8 +48,14 @@ function initialRoute(value: unknown): Route | undefined { if (value.type === "session" && "sessionID" in value && typeof value.sessionID === "string") { return { type: "session", sessionID: value.sessionID } } - if (value.type === "plugin" && "id" in value && typeof value.id === "string") { - return { type: "plugin", id: value.id } + if ( + value.type === "plugin" && + "id" in value && + typeof value.id === "string" && + "name" in value && + typeof value.name === "string" + ) { + return { type: "plugin", id: value.id, name: value.name } } } diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index 281049fe57..ced1cf1a44 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -18,9 +18,14 @@ export type TuiStartup = Readonly<{ skipInitialLoading: boolean }> +export type TuiLifecycle = Readonly<{ + add(finalizer: () => Promise): () => void +}> + const PathsContext = createContext() const TerminalEnvironmentContext = createContext() const StartupContext = createContext() +const LifecycleContext = createContext() function provider(context: ReturnType>, value: T, children: () => JSX.Element) { return createComponent(context.Provider, { @@ -43,6 +48,10 @@ export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Ele return provider(StartupContext, props.value, () => props.children) } +export function TuiLifecycleProvider(props: { value: TuiLifecycle; children: JSX.Element }) { + return provider(LifecycleContext, props.value, () => props.children) +} + function required(context: ReturnType>, name: string) { const value = useContext(context) if (!value) throw new Error(`${name} is missing`) @@ -60,3 +69,7 @@ export function useTuiTerminalEnvironment() { export function useTuiStartup() { return required(StartupContext, "TuiStartupProvider") } + +export function useTuiLifecycle() { + return required(LifecycleContext, "TuiLifecycleProvider") +} diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 9684b71334..1e051ae4d2 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,16 +1,8 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { PluginRuntime } from "../plugin/runtime" -import HomeFooter from "./home/footer" -import HomeTips from "./home/tips" -import SidebarContext from "./sidebar/context" -import SidebarFooter from "./sidebar/footer" -import SidebarLsp from "./sidebar/lsp" -import SidebarMcp from "./sidebar/mcp" -import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" -import Scrap from "./system/scrap" export type BuiltinTuiPlugin = Omit & { id: string @@ -19,25 +11,10 @@ export type BuiltinTuiPlugin = Omit & { } export function createBuiltinPlugins(): BuiltinTuiPlugin[] { - return [ - HomeFooter, - HomeTips, - SidebarContext, - SidebarMcp, - SidebarLsp, - SidebarFooter, - Notifications, - PluginManager, - WhichKey, - Scrap, - DiffViewer, - ] + return [Notifications, PluginManager, WhichKey] } -export async function loadBuiltinPlugins( - api: TuiPluginApi, - runtime: PluginRuntime, -) { +export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { const slots = runtime.setupSlots(api) const dispose: Array<() => void> = [] diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index af1277b5c2..6956ad8678 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,98 +1,66 @@ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" +import { Plugin } from "@opencode-ai/plugin/v2/tui" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { createMemo, Match, Show, Switch } from "solid-js" -import { abbreviateHome } from "../../runtime" -import { useTuiPaths } from "../../context/runtime" -import { useHomeSessionDestination } from "../../routes/home/session-destination" -import { FilePath } from "../../ui/file-path" import { useTerminalDimensions } from "@opentui/solid" +import { useTuiPaths } from "../../context/runtime" +import { useTheme } from "../../context/theme" +import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { abbreviateHome } from "../../runtime" +import { FilePath } from "../../ui/file-path" -const id = "internal:home-footer" - -function Directory(props: { api: TuiPluginApi; maxWidth: number }) { - const theme = () => props.api.theme.current +function Directory(props: { context: Plugin.Context; maxWidth: number }) { + const { theme } = useTheme() const destination = useHomeSessionDestination() const paths = useTuiPaths() - const dir = createMemo(() => { + const directory = createMemo(() => { const selected = destination?.destination() if (!selected || selected.type === "new") return - const branch = - selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined - return { path: abbreviateHome(selected.directory, paths.home), branch } + return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home) }) return ( - - {(value) => { - const suffix = () => (value().branch ? `:${value().branch}` : "") - const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2)) - return ( - - - - - {suffix()} - - - - ) - }} + + {(value) => } ) } -function Mcp(props: { api: TuiPluginApi }) { - const theme = () => props.api.theme.current - const list = createMemo(() => props.api.state.mcp()) - const has = createMemo(() => list().length > 0) - const err = createMemo(() => list().some((item) => item.status === "failed")) - const count = createMemo(() => list().filter((item) => item.status === "connected").length) +function Mcp(props: { context: Plugin.Context }) { + const { theme } = useTheme() + const list = createMemo(() => props.context.data.location.mcp.server.list() ?? []) + const failed = createMemo(() => list().some((item) => item.status.status === "failed")) + const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) return ( - + - + - - + + - 0 ? theme().success : theme().textMuted }}>⊙ + 0 ? theme.success : theme.textMuted }}>⊙ {count()} MCP - /status + /status ) } -function Version(props: { api: TuiPluginApi }) { - const theme = () => props.api.theme.current - - return ( - - {props.api.app.version} - - ) -} - -function View(props: { api: TuiPluginApi }) { +function View(props: { context: Plugin.Context }) { + const { theme } = useTheme() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { - const list = props.api.state.mcp() + const list = props.context.data.location.mcp.server.list() ?? [] if (list.length === 0) return 0 - const count = list.filter((item) => item.status === "connected").length + const count = list.filter((item) => item.status.status === "connected").length return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2 }) - const directoryWidth = createMemo(() => - Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()), - ) + return ( - - + + - + + {InstallationVersion} + ) } -const tui: TuiPlugin = async (api) => { - api.slots.register({ - order: 100, - slots: { - home_footer() { - return - }, - }, - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin +export default Plugin.define({ + id: "opencode.home-footer", + setup(context) { + context.ui.slot("home.footer", () => ) + }, +}) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 92623cde29..8c65dbd603 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -1,12 +1,11 @@ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "../../context/theme" -import { useCommandShortcut } from "../../keymap" +import { Keymap } from "../../context/keymap" const themeCount = Object.keys(DEFAULT_THEMES).length type TipPart = { text: string; highlight: boolean } -type TipShortcut = Accessor +type TipShortcut = Accessor type Shortcuts = { agentCycle: TipShortcut childFirst: TipShortcut @@ -74,61 +73,54 @@ function shortcutText(value: string) { return `{highlight}${value}{/highlight}` } -function commandText(command: string, shortcut: string) { +function commandText(command: string, shortcut: string | undefined) { if (!shortcut) return shortcutText(command) return `${shortcutText(command)} or ${shortcutText(shortcut)}` } -function press(shortcut: string, text: string) { +function press(shortcut: string | undefined, text: string) { if (!shortcut) return undefined return `Press ${shortcutText(shortcut)} ${text}` } -function configShortcut(api: TuiPluginApi, command: string): TipShortcut { - return () => - api.tuiConfig.keybinds - .get(command) - .map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key)))) - .filter(Boolean) - .join(", ") -} - -export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { +export function Tips(props: { connected?: boolean }) { const theme = useTheme().theme + const keymap = Keymap.useShortcuts() const tipOffset = Math.random() + const shortcut = (id: string) => () => keymap.get(id) const shortcuts: Shortcuts = { - agentCycle: useCommandShortcut("agent.cycle"), - childFirst: configShortcut(props.api, "session.child.first"), - childNext: configShortcut(props.api, "session.child.next"), - childPrevious: configShortcut(props.api, "session.child.previous"), - commandList: useCommandShortcut("command.palette.show"), - editorOpen: useCommandShortcut("prompt.editor"), - helpShow: useCommandShortcut("help.show"), - inputClear: useCommandShortcut("prompt.clear"), - inputNewline: useCommandShortcut("input.newline"), - inputPaste: useCommandShortcut("prompt.paste"), - inputUndo: useCommandShortcut("input.undo"), - leader: configShortcut(props.api, "leader"), - messagesCopy: configShortcut(props.api, "messages.copy"), - messagesFirst: configShortcut(props.api, "session.first"), - messagesLast: configShortcut(props.api, "session.last"), - messagesPageDown: configShortcut(props.api, "session.page.down"), - messagesPageUp: configShortcut(props.api, "session.page.up"), - modelCycleRecent: useCommandShortcut("model.cycle_recent"), - modelList: useCommandShortcut("model.list"), - sessionExport: configShortcut(props.api, "session.export"), - sessionInterrupt: configShortcut(props.api, "session.interrupt"), - sessionList: useCommandShortcut("session.list"), - sessionNew: useCommandShortcut("session.new"), - sessionParent: configShortcut(props.api, "session.parent"), - sessionPinToggle: configShortcut(props.api, "session.pin.toggle"), - sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"), - sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"), - sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"), - sessionTimeline: configShortcut(props.api, "session.timeline"), - statusView: useCommandShortcut("opencode.status"), - terminalSuspend: useCommandShortcut("terminal.suspend"), - themeList: useCommandShortcut("theme.switch"), + agentCycle: shortcut("agent.cycle"), + childFirst: shortcut("session.child.first"), + childNext: shortcut("session.child.next"), + childPrevious: shortcut("session.child.previous"), + commandList: shortcut("command.palette.show"), + editorOpen: shortcut("prompt.editor"), + helpShow: shortcut("help.show"), + inputClear: shortcut("prompt.clear"), + inputNewline: shortcut("input.newline"), + inputPaste: shortcut("prompt.paste"), + inputUndo: shortcut("input.undo"), + leader: shortcut("leader"), + messagesCopy: shortcut("messages.copy"), + messagesFirst: shortcut("session.first"), + messagesLast: shortcut("session.last"), + messagesPageDown: shortcut("session.page.down"), + messagesPageUp: shortcut("session.page.up"), + modelCycleRecent: shortcut("model.cycle_recent"), + modelList: shortcut("model.list"), + sessionExport: shortcut("session.export"), + sessionInterrupt: shortcut("session.interrupt"), + sessionList: shortcut("session.list"), + sessionNew: shortcut("session.new"), + sessionParent: shortcut("session.parent"), + sessionPinToggle: shortcut("session.pin.toggle"), + sessionQuickSwitch1: shortcut("session.quick_switch.1"), + sessionQuickSwitch9: shortcut("session.quick_switch.9"), + sessionSidebarToggle: shortcut("session.sidebar.toggle"), + sessionTimeline: shortcut("session.timeline"), + statusView: shortcut("opencode.status"), + terminalSuspend: shortcut("terminal.suspend"), + themeList: shortcut("theme.switch"), } const tip = createMemo(() => { if (props.connected === false) return NO_MODELS_TIP @@ -175,22 +167,30 @@ const TIPS: Tip[] = [ (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`, (shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"), - (shortcuts) => - shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() - ? `Use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch pinned sessions` - : undefined, + (shortcuts) => { + const first = shortcuts.sessionQuickSwitch1() + const last = shortcuts.sessionQuickSwitch9() + if (!first || !last) return undefined + return `Use ${shortcutText(first)} through ${shortcutText(last)} to switch pinned sessions` + }, "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", - (shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`, + (shortcuts) => { + const leader = shortcuts.leader() + if (!leader) return undefined + return `The leader key is ${shortcutText(leader)}; combine with other keys for quick actions` + }, (shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"), (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), - (shortcuts) => - shortcuts.messagesPageUp() && shortcuts.messagesPageDown() - ? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history` - : undefined, + (shortcuts) => { + const up = shortcuts.messagesPageUp() + const down = shortcuts.messagesPageDown() + if (!up || !down) return undefined + return `Use ${shortcutText(up)}/${shortcutText(down)} to navigate through conversation history` + }, (shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"), (shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"), (shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"), @@ -204,7 +204,7 @@ const TIPS: Tip[] = [ shortcuts.childFirst(), shortcuts.childPrevious(), shortcuts.childNext(), - ].filter(Boolean) + ].filter((item): item is string => Boolean(item)) if (!items.length) return undefined return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions` }, @@ -267,10 +267,12 @@ const TIPS: Tip[] = [ (shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`, (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`, "Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling", - (shortcuts) => - shortcuts.commandList() - ? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})` - : "Toggle username display in chat via the command palette", + (shortcuts) => { + const commandList = shortcuts.commandList() + return commandList + ? `Toggle username display in chat via the command palette (${shortcutText(commandList)})` + : "Toggle username display in chat via the command palette" + }, "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} in a container", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx index 2c516b8f32..0fedb29be5 100644 --- a/packages/tui/src/feature-plugins/home/tips.tsx +++ b/packages/tui/src/feature-plugins/home/tips.tsx @@ -1,66 +1,51 @@ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" +import { Plugin } from "@opencode-ai/plugin/v2/tui" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" -import { useBindings } from "../../keymap" +import { Keymap } from "../../context/keymap" import { useData } from "../../context/data" import { hasConnectedProvider } from "../../util/connected-provider" import { useConfig } from "../../config" +import { useDialog } from "../../ui/dialog" -const id = "internal:home-tips" - -function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) { +function View() { const config = useConfig() - useBindings(() => ({ + const data = useData() + const dialog = useDialog() + const hidden = createMemo(() => !(config.data.hints?.tips ?? true)) + const first = createMemo(() => data.session.list().length === 0) + const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) + const show = createMemo(() => (!first() || !connected()) && !hidden()) + + Keymap.createLayer(() => ({ commands: [ { - name: "tips.toggle", - title: props.hidden ? "Show tips" : "Hide tips", - category: "System", - namespace: "palette", - hidden: true, + id: "tips.toggle", + title: hidden() ? "Show tips" : "Hide tips", + group: "System", run() { void config .update((draft) => { - draft.hints = { ...draft.hints, tips: props.hidden } + draft.hints = { ...draft.hints, tips: hidden() } }) .catch(() => {}) - props.api.ui.dialog.clear() + dialog.clear() }, }, ], - bindings: props.api.tuiConfig.keybinds.get("tips.toggle"), })) return ( - - + + ) } -const tui: TuiPlugin = async (api) => { - api.slots.register({ - order: 100, - slots: { - home_bottom() { - const data = useData() - const config = useConfig().data - const hidden = createMemo(() => !(config.hints?.tips ?? true)) - const first = createMemo(() => api.state.session.count() === 0) - const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) - const show = createMemo(() => (!first() || !connected()) && !hidden()) - return diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 2d32055e4f..fa4c3232ec 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -5,7 +5,7 @@ import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" -import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { Keymap } from "../../context/keymap" import { contextUsage } from "../../util/session" const money = new Intl.NumberFormat("en-US", { @@ -47,10 +47,8 @@ export function SubagentFooter() { }) const { theme } = useTheme() - const keymap = useOpencodeKeymap() - const parentShortcut = useCommandShortcut("session.parent") - const previousShortcut = useCommandShortcut("session.child.previous") - const nextShortcut = useCommandShortcut("session.child.next") + const keymap = Keymap.use() + const shortcuts = Keymap.useShortcuts() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) useTerminalDimensions() @@ -84,31 +82,31 @@ export function SubagentFooter() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatchCommand("session.parent")} + onMouseUp={() => keymap.dispatch("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > - Parent {parentShortcut()} + Parent {shortcuts.get("session.parent")} setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatchCommand("session.child.previous")} + onMouseUp={() => keymap.dispatch("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > - Prev {previousShortcut()} + Prev {shortcuts.get("session.child.previous")} setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatchCommand("session.child.next")} + onMouseUp={() => keymap.dispatch("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > - Next {nextShortcut()} + Next {shortcuts.get("session.child.next")} diff --git a/packages/tui/src/ui/dialog-alert.tsx b/packages/tui/src/ui/dialog-alert.tsx index 9fe15de6b7..9983982723 100644 --- a/packages/tui/src/ui/dialog-alert.tsx +++ b/packages/tui/src/ui/dialog-alert.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { useBindings } from "../keymap" export type DialogAlertProps = { title: string @@ -13,13 +13,14 @@ export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "return", - desc: "Confirm alert", + bind: "return", + title: "Confirm alert", group: "Dialog", - cmd: () => { + run: () => { props.onConfirm?.() dialog.clear() }, diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index a09847af2f..c9bb952fd5 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -1,10 +1,10 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" import { Locale } from "../util/locale" -import { useBindings } from "../keymap" export type DialogConfirmProps = { title: string @@ -23,31 +23,32 @@ export function DialogConfirm(props: DialogConfirmProps) { active: "confirm" as "confirm" | "cancel", }) - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "return", - desc: "Confirm dialog selection", + bind: "return", + title: "Confirm dialog selection", group: "Dialog", - cmd: () => { + run: () => { if (store.active === "confirm") props.onConfirm?.() if (store.active === "cancel") props.onCancel?.() dialog.clear() }, }, { - key: "left", - desc: "Previous dialog option", + bind: "left", + title: "Previous dialog option", group: "Dialog", - cmd: () => { + run: () => { setStore("active", store.active === "confirm" ? "cancel" : "confirm") }, }, { - key: "right", - desc: "Next dialog option", + bind: "right", + title: "Next dialog option", group: "Dialog", - cmd: () => { + run: () => { setStore("active", store.active === "confirm" ? "cancel" : "confirm") }, }, diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index db05529158..6e5b9f472f 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For, Show } from "solid-js" -import { useBindings } from "../keymap" export type ExportFormat = "markdown" | "json" @@ -43,13 +43,14 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { if (store.active === "copy" || store.active === "export") confirm(store.active) } - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "tab", - desc: "Next export option", + bind: "tab", + title: "Next export option", group: "Dialog", - cmd: () => { + run: () => { const order: Active[] = store.format === "markdown" ? ["markdown", "json", "thinking", "copy", "export"] @@ -58,10 +59,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { }, }, { - key: "return", - desc: "Select export option", + bind: "return", + title: "Select export option", group: "Dialog", - cmd: activate, + run: activate, }, ], })) diff --git a/packages/tui/src/ui/dialog-export-result.tsx b/packages/tui/src/ui/dialog-export-result.tsx index 672590867a..f7d7cb199e 100644 --- a/packages/tui/src/ui/dialog-export-result.tsx +++ b/packages/tui/src/ui/dialog-export-result.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { useBindings } from "../keymap" import { useDialog, type DialogContext } from "./dialog" export function DialogExportResult(props: { path: string; onClose?: () => void }) { @@ -12,13 +12,14 @@ export function DialogExportResult(props: { path: string; onClose?: () => void } dialog.clear() } - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "return", - desc: "Close export result", + bind: "return", + title: "Close export result", group: "Dialog", - cmd: close, + run: close, }, ], })) @@ -37,12 +38,7 @@ export function DialogExportResult(props: { path: string; onClose?: () => void } {props.path} - + Close diff --git a/packages/tui/src/ui/dialog-help.tsx b/packages/tui/src/ui/dialog-help.tsx index 1d49d60edf..a78b1f7d96 100644 --- a/packages/tui/src/ui/dialog-help.tsx +++ b/packages/tui/src/ui/dialog-help.tsx @@ -1,17 +1,18 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "./dialog" -import { useBindings, useCommandShortcut } from "../keymap" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() - const commandShortcut = useCommandShortcut("command.palette.show") + const shortcuts = Keymap.useShortcuts() - useBindings(() => ({ - bindings: [ - { key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, - { key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "return", title: "Close help", group: "Dialog", run: () => dialog.clear() }, + { bind: "escape", title: "Close help", group: "Dialog", run: () => dialog.clear() }, ], })) @@ -27,7 +28,7 @@ export function DialogHelp() { - Press {commandShortcut()} to see all available actions and commands in any context. + Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context. diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index 76cef90070..be89c9a8d4 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -1,10 +1,9 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" -import { useConfig } from "../config" -import { useBindings, useCommandShortcut } from "../keymap" export type DialogPromptProps = { title: string @@ -20,8 +19,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() - const config = useConfig().data - const submitShortcut = useCommandShortcut("dialog.prompt.submit") + const shortcuts = Keymap.useShortcuts() const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable @@ -30,20 +28,21 @@ export function DialogPrompt(props: DialogPromptProps) { props.onConfirm?.(textarea.plainText) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", target: textareaTarget, enabled: textareaTarget() !== undefined && !props.busy, // Dialog form semantics must win over the global managed textarea input layer. priority: 1, commands: [ { - name: "dialog.prompt.submit", + id: "dialog.prompt.submit", title: "Submit dialog prompt", - category: "Dialog", + bind: "return", + group: "Dialog", run: confirm, }, ], - bindings: config.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]), })) onMount(() => { @@ -103,9 +102,9 @@ export function DialogPrompt(props: DialogPromptProps) { processing...}> - + - {submitShortcut()} submit + {shortcuts.get("dialog.prompt.submit")} submit diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 40ad0f24b1..fc5261de93 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -1,12 +1,5 @@ -import { - InputRenderable, - RGBA, - ScrollBoxRenderable, - TextAttributes, - type KeyEvent, - type Renderable, -} from "@opentui/core" -import type { Binding } from "@opentui/keymap" +import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { Keymap, type KeymapCommand } from "../context/keymap" import { useTheme, selectedForeground } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" @@ -18,7 +11,7 @@ import { useDialog, type DialogContext } from "./dialog" import { Locale } from "../util/locale" import { getScrollAcceleration } from "../util/scroll" import { useConfig } from "../config" -import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" +import { formatKeyBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string @@ -26,6 +19,7 @@ export interface DialogSelectProps { placeholder?: string footer?: JSX.Element emptyView?: JSX.Element + noMatchView?: JSX.Element options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void @@ -42,7 +36,7 @@ export interface DialogSelectProps { label: string side?: "left" | "right" }[] - bindings?: readonly Binding[] + bindings?: readonly KeymapCommand[] current?: T focusCurrent?: boolean } @@ -155,11 +149,10 @@ export function DialogSelect(props: DialogSelectProps) { .filter((item) => item.label), ...(props.footerHints ?? []), ]) - const actionItems = createMemo(() => + const actionItems = () => visibleActions() .filter(isActionItem) - .filter((item) => !isActionDisabled(item)), - ) + .filter((item) => !isActionDisabled(item)) createEffect(() => { const index = focusedAction() @@ -385,51 +378,52 @@ export function DialogSelect(props: DialogSelectProps) { }) } - useBindings(() => { + Keymap.createLayer(() => { const visible = shownActions() return { + mode: "modal", commands: [ { - name: "dialog.select.prev", + id: "dialog.select.prev", title: "Previous item", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(-1) }, }, { - name: "dialog.select.next", + id: "dialog.select.next", title: "Next item", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(1) }, }, { - name: "dialog.select.page_up", + id: "dialog.select.page_up", title: "Page up", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(-10) }, }, { - name: "dialog.select.page_down", + id: "dialog.select.page_down", title: "Page down", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(10) }, }, { - name: "dialog.select.home", + id: "dialog.select.home", title: "First item", - category: "Dialog", + group: "Dialog", run() { if (props.locked) return setStore("input", "keyboard") @@ -437,9 +431,9 @@ export function DialogSelect(props: DialogSelectProps) { }, }, { - name: "dialog.select.end", + id: "dialog.select.end", title: "Last item", - category: "Dialog", + group: "Dialog", run() { if (props.locked) return setStore("input", "keyboard") @@ -447,49 +441,34 @@ export function DialogSelect(props: DialogSelectProps) { }, }, { - name: "dialog.select.submit", + id: "dialog.select.submit", title: "Select item", - category: "Dialog", + group: "Dialog", run: submit, }, ...visible.map((item) => ({ - name: item.command, + id: item.command, title: item.title, - category: "Dialog", + group: "Dialog", run: () => trigger(item), })), - ], - bindings: [ - ...config.keybinds.gather("dialog.select", [ - "dialog.select.prev", - "dialog.select.next", - "dialog.select.page_up", - "dialog.select.page_down", - "dialog.select.home", - "dialog.select.end", - "dialog.select.submit", - ]), - ...visible.flatMap((item) => config.keybinds.get(item.command)), ...(visible.length ? [ { - key: "tab", - desc: "Next dialog action", + bind: "tab", + title: "Next dialog action", group: "Dialog", - cmd: () => moveAction(1), + run: () => moveAction(1), }, { - key: "shift+tab", - desc: "Previous dialog action", + bind: "shift+tab", + title: "Previous dialog action", group: "Dialog", - cmd: () => moveAction(-1), + run: () => moveAction(-1), }, ] : []), - ...(props.bindings ?? []).filter((binding) => { - if (typeof binding.cmd !== "string") return true - return visible.some((item) => item.command === binding.cmd) - }), + ...(props.bindings ?? []), ], } }) @@ -616,11 +595,22 @@ export function DialogSelect(props: DialogSelectProps) { 0} fallback={ - props.emptyView ?? ( - - No results found - - ) + 0} + fallback={ + props.emptyView ?? ( + + No items available + + ) + } + > + {props.noMatchView ?? ( + + No results found + + )} + } > { if (store.stack.length === 0) return - const popMode = modeStack.push("modal") + const popMode = keymap.mode.push("modal") onCleanup(popMode) }) @@ -106,14 +106,15 @@ function init() { }, 1) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), - bindings: [ + commands: [ { - key: "escape", - desc: "Close dialog", + bind: "escape", + title: "Close dialog", group: "Dialog", - cmd: () => { + run: () => { if (renderer.getSelection()) { renderer.clearSelection() } @@ -124,10 +125,10 @@ function init() { }, }, { - key: "ctrl+c", - desc: "Close dialog", + bind: "ctrl+c", + title: "Close dialog", group: "Dialog", - cmd: () => { + run: () => { if (renderer.getSelection()) { renderer.clearSelection() } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d5b1736a5a..bad8245fcd 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,5 +1,4 @@ import { expect, mock, test } from "bun:test" -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createTestRenderer } from "@opentui/core/testing" import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -11,37 +10,29 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const titles: string[] = [] + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) setup.renderer.setTerminalTitle = (title) => { titles.push(title) + if (title === "OpenCode") started() setTitle(title) } const listeners = new Set(process.listeners("SIGHUP")) const events = createEventStream() const calls = createFetch(undefined, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) - let started!: () => void - const ready = new Promise((resolve) => { - started = resolve - }) - let disposes = 0 - try { const { run } = await import("../src/app") const task = Effect.runPromise( run({ server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, args: {}, log: () => {}, - pluginHost: { - async start() { - started() - }, - async dispose() { - disposes++ - }, - }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), ) await ready @@ -50,7 +41,6 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { expect(setup.renderer.isDestroyed).toBe(true) expect(titles.at(-1)).toBe("") - expect(disposes).toBe(1) expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true) } finally { if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -101,12 +91,6 @@ test("session lifecycle updates the terminal title and prints the epilogue after const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" - let api: TuiPluginApi | undefined - let started!: () => void - const ready = new Promise((resolve) => { - started = resolve - }) - process.stdout.write = ((chunk: string | Uint8Array) => { stdout += String(chunk) return true @@ -118,19 +102,12 @@ test("session lifecycle updates the terminal title and prints the epilogue after run({ server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, args: { sessionID: "dummy" }, log: () => {}, - pluginHost: { - async start(input) { - api = input.api - started() - }, - async dispose() {}, - }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), ) - await ready await initialTitleSet events.emit({ id: "evt_renamed", @@ -140,7 +117,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after data: { sessionID: "dummy", title: "Renamed session" }, }) await renamedTitleSet - api?.keymap.dispatchCommand("app.exit") + setup.renderer.destroy() await task expect(stdout).toContain("Renamed session") diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 2671ba1e22..493c290325 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -42,6 +42,90 @@ function durable(sessionID: string, seq = 0, version = 1) { return { aggregateID: sessionID, seq, version } } +test("bootstraps MCP data for the TUI location", async () => { + const events = createEventStream() + const requests: URL[] = [] + const calls = createFetch((url) => { + if (url.pathname === "/api/mcp" || url.pathname === "/api/mcp/resource") requests.push(url) + return undefined + }, events) + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => requests.length === 2) + expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([ + process.cwd(), + process.cwd(), + ]) + } finally { + app.renderer.destroy() + } +}) + +test("refreshes MCP status when a connection settles during bootstrap", async () => { + const events = createEventStream() + let mcpRequests = 0 + let resolveModels!: (response: Response) => void + const calls = createFetch((url) => { + if (url.pathname === "/api/mcp") { + mcpRequests++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [{ name: "context7", status: { status: mcpRequests === 1 ? "pending" : "connected" } }], + }) + } + if (url.pathname === "/api/model") + return new Promise((resolve) => { + resolveModels = resolve + }) + return undefined + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.location.mcp.server.list()?.[0]?.status.status === "pending") + emitEvent(events, { + id: "evt_mcp_connected", + created: 1, + type: "mcp.status.changed", + data: { server: "context7" }, + }) + await wait(() => data.location.mcp.server.list()?.[0]?.status.status === "connected") + expect(mcpRequests).toBe(2) + resolveModels(json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })) + } finally { + app.renderer.destroy() + } +}) + test("refreshes resources into reactive getters", async () => { const events = createEventStream() const location = { @@ -2145,11 +2229,35 @@ test("settles pending tools when a live failure arrives", async () => { state: { call: true }, }, }) + emitEvent(events, { + id: "evt_progress_1", + created: 0, + type: "session.tool.progress", + durable: durable("session-1", 5), + data: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + structured: { sessionID: "session-child", status: "running" }, + content: [], + }, + }) + + await wait(() => { + const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") + return ( + assistant?.type === "assistant" && + assistant.content[0]?.type === "tool" && + assistant.content[0].state.status === "running" && + assistant.content[0].state.structured.sessionID === "session-child" + ) + }) + emitEvent(events, { id: "evt_failed_1", created: 0, type: "session.tool.failed", - durable: durable("session-1", 5), + durable: durable("session-1", 6), data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", @@ -2180,7 +2288,7 @@ test("settles pending tools when a live failure arrives", async () => { if (tool.state.status !== "error") return expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) expect(tool.state.input).toEqual({}) - expect(tool.state.structured).toEqual({}) + expect(tool.state.structured).toEqual({ sessionID: "session-child", status: "running" }) expect(tool.state.content).toEqual([]) expect(tool.executed).toBe(false) expect(tool.providerState).toEqual({ call: true }) @@ -2301,7 +2409,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn } }) -test("projects live instruction updates with their message ID", async () => { +test("skips initial instruction state and projects later updates with their message ID", async () => { const events = createEventStream() const calls = createFetch(undefined, events) let sync!: ReturnType @@ -2335,18 +2443,30 @@ test("projects live instruction updates with their message ID", async () => { created: 0, type: "session.instructions.updated", durable: durable("session-1", 0, 2), + metadata: { instructions: { initial: true } }, data: { sessionID: "session-1", delta: { "core/date": "0".repeat(64) }, }, }) + emitEvent(events, { + id: "evt_instructions_2", + created: 1, + type: "session.instructions.updated", + durable: durable("session-1", 1, 2), + data: { + sessionID: "session-1", + delta: { "core/date": "1".repeat(64) }, + }, + }) - await wait(() => sync.session.message.list("session-1")?.length === 1) + await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 1)) + expect(sync.session.message.list("session-1")).toHaveLength(1) expect(sync.session.message.list("session-1")?.[0]).toMatchObject({ - id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_instructions_1")), + id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_instructions_2")), type: "system", text: "Instructions updated: core/date", - time: { created: 0 }, + time: { created: 1 }, }) } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index d648258746..b2716d0559 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -1,7 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { TextareaRenderable } from "@opentui/core" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { testRender, useRenderer } from "@opentui/solid" +import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -27,31 +26,26 @@ async function mountPrompt(input: { const state = path.join(input.root, "state") await mkdir(state, { recursive: true }) - const [ - { DialogProvider }, - { DialogPrompt }, - { ThemeProvider }, - { ConfigProvider }, - { ToastProvider }, - { OpencodeKeymapProvider, registerOpencodeKeymap }, - ] = await Promise.all([ - import("../../../src/ui/dialog"), - import("../../../src/ui/dialog-prompt"), - import("../../../src/context/theme"), - import("../../../src/config"), - import("../../../src/ui/toast"), - import("../../../src/keymap"), - ]) + const [{ DialogProvider }, { DialogPrompt }, { ThemeProvider }, { ConfigProvider }, { ToastProvider }, { Keymap }] = + await Promise.all([ + import("../../../src/ui/dialog"), + import("../../../src/ui/dialog-prompt"), + import("../../../src/context/theme"), + import("../../../src/config"), + import("../../../src/ui/toast"), + import("../../../src/context/keymap"), + ]) function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) const resolvedConfig = createTuiResolvedConfig({ keybinds: input.keybinds, leader: { timeout: 1000 }, }) - const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) - onCleanup(off) + + function Prompt() { + onCleanup(Keymap.use().mode.push("modal")) + return + } return ( - - + + - + - - + + ) } const app = await testRender(() => , { kittyKeyboard: true }) + app.renderer.start() return { app, async cleanup() { diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 96c849186d..5e6a28e9be 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -1,7 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { InputRenderable } from "@opentui/core" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { testRender, useRenderer } from "@opentui/solid" +import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -16,61 +15,59 @@ async function renderSelect( options: DialogSelectOption[], onGlobal: () => void, onRow: (option: DialogSelectOption) => void, + current?: string, ) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) const config = createTuiResolvedConfig() - const [ - { ConfigProvider }, - { ThemeProvider }, - { OpencodeKeymapProvider, registerOpencodeKeymap }, - { DialogProvider }, - { DialogSelect }, - { ToastProvider }, - ] = await Promise.all([ - import("../../../src/config"), - import("../../../src/context/theme"), - import("../../../src/keymap"), - import("../../../src/ui/dialog"), - import("../../../src/ui/dialog-select"), - import("../../../src/ui/toast"), - ]) + const [{ ConfigProvider }, { ThemeProvider }, { Keymap }, { DialogProvider }, { DialogSelect }, { ToastProvider }] = + await Promise.all([ + import("../../../src/config"), + import("../../../src/context/theme"), + import("../../../src/context/keymap"), + import("../../../src/ui/dialog"), + import("../../../src/ui/dialog-select"), + import("../../../src/ui/toast"), + ]) function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const off = registerOpencodeKeymap(keymap, renderer, config) - onCleanup(off) + function Select() { + onCleanup(Keymap.use().mode.push("modal")) + return ( + + ) + } return ( - - + + Promise.resolve({}) }}> - +