chore: update merge branch with latest v2
This commit is contained in:
commit
890b359be9
141 changed files with 3550 additions and 2526 deletions
Binary file not shown.
|
|
@ -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)))
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
|
|
@ -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<Node extends Spec.Any> = () => Promise<{
|
||||
default: (
|
||||
|
|
@ -25,7 +26,7 @@ type Loader<Node extends Spec.Any> = () => 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<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ export interface EntryPoint {
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly add: (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Op
|
|||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target)).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
if (!entrypoint) return
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
|
|
@ -194,40 +188,10 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
|||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
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<void>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -113,12 +113,13 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
23
packages/llm/src/protocols/openai-compatible-responses.ts
Normal file
23
packages/llm/src/protocols/openai-compatible-responses.ts
Normal file
|
|
@ -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"
|
||||
|
|
@ -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<LLMEvent>]
|
|||
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<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
55
packages/llm/src/providers/openai-compatible-responses.ts
Normal file
55
packages/llm/src/providers/openai-compatible-responses.ts
Normal file
|
|
@ -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<Settings>["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)
|
||||
|
|
@ -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<unknown>
|
|||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) =>
|
||||
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<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared> {
|
||||
|
|
@ -298,6 +321,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||
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<Body, Prepared>
|
||||
|
|
|
|||
|
|
@ -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<string, string>) => {
|
|||
)
|
||||
}
|
||||
|
||||
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
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<string | RegExp>) =>
|
||||
(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,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.
|
|||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly content?: ReadonlyArray<Content>
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Tool.Context, "progress"> & {
|
||||
readonly progress: (update: Tool.Progress) => Promise<void>
|
||||
}
|
||||
export type SchemaType<A> = Tool.SchemaType<A>
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
||||
|
|
@ -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<string, any>
|
||||
}
|
||||
|
||||
export type Destination = Route | Omit<Extract<Route, { readonly type: "plugin" }>, "id">
|
||||
|
||||
export interface Page {
|
||||
readonly name: string
|
||||
readonly render: (input: { readonly params: any }) => JSX.Element
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
register(definition: RouteDefinition): () => void
|
||||
navigate(input: { readonly name: string; readonly params?: any }): void
|
||||
current(): {
|
||||
export type Slot = (props: Record<string, any>) => 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<void>
|
||||
}
|
||||
|
||||
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<string, any>
|
||||
readonly options: Readonly<Record<string, any>>
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
readonly keymap: Keymap
|
||||
readonly ui: UI
|
||||
}
|
||||
|
|
|
|||
1
packages/plugin/src/v2/tui/index.ts
Normal file
1
packages/plugin/src/v2/tui/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * as Plugin from "./plugin.js"
|
||||
14
packages/plugin/src/v2/tui/plugin.ts
Normal file
14
packages/plugin/src/v2/tui/plugin.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { Context } from "./context.js"
|
||||
|
||||
export type { Context }
|
||||
|
||||
export type Cleanup = () => Promise<void> | void
|
||||
|
||||
export interface Definition {
|
||||
readonly id: string
|
||||
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
|
||||
}
|
||||
|
||||
export function define(plugin: Definition) {
|
||||
return plugin
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void>>()
|
||||
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<unknown>()
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<ClientProvider
|
||||
api={api}
|
||||
reconnect={reconnect}
|
||||
reload={input.server.reload}
|
||||
>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
|
|
@ -349,17 +342,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<LocationProvider>
|
||||
<App
|
||||
pluginHost={input.pluginHost}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</LocationProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
|
|
@ -369,19 +363,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</EpilogueProvider>
|
||||
|
|
@ -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(() => <DialogSessionList />)
|
||||
},
|
||||
|
|
@ -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(() => <DialogModel />)
|
||||
},
|
||||
|
|
@ -712,7 +666,7 @@ function App(props: {
|
|||
name: "agent.list",
|
||||
title: "Switch agent",
|
||||
category: "Agent",
|
||||
slashName: "agents",
|
||||
slash: { name: "agents" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogAgent />)
|
||||
},
|
||||
|
|
@ -721,7 +675,7 @@ function App(props: {
|
|||
name: "mcp.list",
|
||||
title: "MCP servers",
|
||||
category: "Agent",
|
||||
slashName: "mcps",
|
||||
slash: { name: "mcps" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogMcp />)
|
||||
},
|
||||
|
|
@ -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(() => (
|
||||
<DialogIntegration
|
||||
|
|
@ -786,7 +740,7 @@ function App(props: {
|
|||
{
|
||||
name: "opencode.settings",
|
||||
title: "Open settings",
|
||||
slashName: "settings",
|
||||
slash: { name: "settings" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogConfig />)
|
||||
},
|
||||
|
|
@ -795,7 +749,7 @@ function App(props: {
|
|||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
slashName: "status",
|
||||
slash: { name: "status" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogStatus />)
|
||||
},
|
||||
|
|
@ -804,7 +758,7 @@ function App(props: {
|
|||
{
|
||||
name: "server.pair",
|
||||
title: "Pair device",
|
||||
slashName: "pair",
|
||||
slash: { name: "pair" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogPair credentials={props.pair} />)
|
||||
},
|
||||
|
|
@ -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(() => <DialogDebug />)
|
||||
},
|
||||
|
|
@ -841,7 +795,7 @@ function App(props: {
|
|||
{
|
||||
name: "theme.switch",
|
||||
title: "Switch theme",
|
||||
slashName: "themes",
|
||||
slash: { name: "themes" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogThemeList />)
|
||||
},
|
||||
|
|
@ -871,7 +825,7 @@ function App(props: {
|
|||
{
|
||||
name: "help.show",
|
||||
title: "Help",
|
||||
slashName: "help",
|
||||
slash: { name: "help" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogHelp />)
|
||||
},
|
||||
|
|
@ -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 <PluginRouteMissing id={route.data.id} onHome={() => 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: {
|
|||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Show when={ready()}>
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
|
|
@ -1155,16 +1103,22 @@ function App(props: {
|
|||
{(_) => <Session />}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={route.data.type === "plugin"}>
|
||||
<PluginRoute
|
||||
fallback={(id, name) => (
|
||||
<PluginRouteMissing id={id} name={name} onHome={() => route.navigate({ type: "home" })} />
|
||||
)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
{plugin()}
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<pluginRuntime.Slot name="app_bottom" />
|
||||
<PluginSlot name="app.bottom" />
|
||||
</box>
|
||||
<pluginRuntime.Slot name="app" />
|
||||
<PluginSlot name="app" />
|
||||
</Show>
|
||||
<Show when={!startup.skipInitialLoading}>
|
||||
<StartupLoading ready={ready} />
|
||||
<StartupLoading ready={plugins.ready} />
|
||||
</Show>
|
||||
<Show when={showReconnecting()}>
|
||||
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<text fg={theme.textMuted}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<typeof setTimeout> | 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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
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 ? (
|
||||
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
||||
) : deleting ? (
|
||||
<span style={{ fg: theme.text }}>Press {deleteHint()} again to confirm</span>
|
||||
<span style={{ fg: theme.text }}>Press {shortcuts.get("dialog.move_session.delete")} again to confirm</span>
|
||||
) : suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
|
|
@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
options={options()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : undefined
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
current={current()}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<box
|
||||
flexDirection={horizontal() ? "row" : "column"}
|
||||
alignItems={horizontal() ? "flex-start" : "center"}
|
||||
gap={2}
|
||||
>
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.textMuted}>URLs</text>
|
||||
|
|
@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
{showPassword() ? value.password : "************"}
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}
|
||||
>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
|
|
@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={loadError()}>
|
||||
{(error) => <text fg={theme.error}>{errorMessage(error())}</text>}
|
||||
</Show>
|
||||
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information...</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
<scrollbox
|
||||
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
<Show
|
||||
when={loadError()}
|
||||
fallback={
|
||||
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information…</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
<scrollbox
|
||||
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
{content()}
|
||||
</scrollbox>
|
||||
}
|
||||
>
|
||||
{content()}
|
||||
</scrollbox>
|
||||
}
|
||||
>
|
||||
{content()}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(error) => (
|
||||
<box>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load server information
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(error())}</text>
|
||||
<text fg={theme.textMuted}>Close and reopen Pair to try again.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<InputRenderable>()
|
||||
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 <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.textMuted }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -82,7 +80,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
|||
|
||||
DialogProjectCopyName.show = (dialog: DialogContext) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
|
||||
dialog.replace(
|
||||
() => <DialogProjectCopyName onConfirm={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
|
||||
function slugify(input: string) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
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={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No sessions available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={searchState().error ? theme.error : theme.textMuted}>{searchState().message}</text>
|
||||
</box>
|
||||
}
|
||||
onMove={() => setToDelete(undefined)}
|
||||
onSelect={(option) => {
|
||||
route.navigate({ type: "session", sessionID: option.value })
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<DialogSelect
|
||||
title="Skills"
|
||||
options={options()}
|
||||
renderFilter={!showError()}
|
||||
locked={showError()}
|
||||
renderFilter={!showError() && !skills.loading}
|
||||
locked={showError() || skills.loading}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
</box>
|
||||
) : undefined
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.textMuted}>Close and reopen Skills to try again.</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<number>()
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
|
||||
<text fg={theme.warning}>
|
||||
Unknown plugin route: {props.id}/{props.name}
|
||||
</text>
|
||||
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.text}>go home</text>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<box
|
||||
|
|
@ -738,7 +745,7 @@ export function Autocomplete(props: {
|
|||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>No matching items</text>
|
||||
<text fg={emptyError() ? theme.error : theme.textMuted}>{emptyMessage()}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(() => (
|
||||
<DialogSkill
|
||||
|
|
@ -520,7 +520,7 @@ export function Prompt(props: PromptProps) {
|
|||
desc: "Move to another project dir",
|
||||
name: "session.move",
|
||||
category: "Session",
|
||||
slashName: "move",
|
||||
slash: { name: "move" },
|
||||
run: () => {
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createStore, reconcile } from "solid-js/store"
|
|||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
export interface Interface {
|
||||
readonly path?: string
|
||||
readonly get: () => Promise<Info>
|
||||
readonly update: (update: (draft: any) => void) => Promise<Info>
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
|
||||
<ConfigContext.Provider value={{ data: config, path: host?.path, update }}>{props.children}</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
332
packages/tui/src/context/keymap.tsx
Normal file
332
packages/tui/src/context/keymap.tsx
Normal file
|
|
@ -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<typeof KeymapProvider>[0]["keymap"]
|
||||
type Mode = ReturnType<typeof createMode>
|
||||
|
||||
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 (
|
||||
<KeymapProvider keymap={keymap}>
|
||||
<Context.Provider value={{ keymap, mode }}>{props.children}</Context.Provider>
|
||||
</KeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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<KeymapCommand & { readonly id: string }>,
|
||||
inline: [] as Array<KeymapCommand & { readonly id?: undefined; readonly bind: string }>,
|
||||
},
|
||||
)
|
||||
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<readonly KeymapCommand[]> {
|
||||
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<typeof useConfig>["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
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export type SessionRoute = {
|
|||
export type PluginRoute = {
|
||||
type: "plugin"
|
||||
id: string
|
||||
name: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,14 @@ export type TuiStartup = Readonly<{
|
|||
skipInitialLoading: boolean
|
||||
}>
|
||||
|
||||
export type TuiLifecycle = Readonly<{
|
||||
add(finalizer: () => Promise<void>): () => void
|
||||
}>
|
||||
|
||||
const PathsContext = createContext<TuiPaths>()
|
||||
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
|
||||
const StartupContext = createContext<TuiStartup>()
|
||||
const LifecycleContext = createContext<TuiLifecycle>()
|
||||
|
||||
function provider<T>(context: ReturnType<typeof createContext<T>>, 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<T>(context: ReturnType<typeof createContext<T>>, 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TuiPluginModule, "id"> & {
|
||||
id: string
|
||||
|
|
@ -19,25 +11,10 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
|||
}
|
||||
|
||||
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> = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Show when={dir()}>
|
||||
{(value) => {
|
||||
const suffix = () => (value().branch ? `:${value().branch}` : "")
|
||||
const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
|
||||
return (
|
||||
<box flexDirection="row" minWidth={0}>
|
||||
<FilePath
|
||||
value={value().path}
|
||||
maxWidth={Math.max(2, props.maxWidth - suffixWidth())}
|
||||
fg={theme().textMuted}
|
||||
/>
|
||||
<Show when={suffix()}>
|
||||
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
|
||||
{suffix()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={theme.textMuted} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Show when={has()}>
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme().text}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={err()}>
|
||||
<span style={{ fg: theme().error }}>⊙ </span>
|
||||
<Match when={failed()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}>⊙ </span>
|
||||
<span style={{ fg: count() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={theme().textMuted}>/status</text>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Version(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme().textMuted}>{props.api.app.version}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<box
|
||||
width="100%"
|
||||
|
|
@ -104,28 +72,22 @@ function View(props: { api: TuiPluginApi }) {
|
|||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Directory api={props.api} maxWidth={directoryWidth()} />
|
||||
<Mcp api={props.api} />
|
||||
<Directory
|
||||
context={props.context}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
|
||||
/>
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<Version api={props.api} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{InstallationVersion}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
home_footer() {
|
||||
return <View api={api} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
export default Plugin.define({
|
||||
id: "opencode.home-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("home.footer", () => <View context={context} />)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
type TipShortcut = Accessor<string | undefined>
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={props.show}>
|
||||
<Tips api={props.api} connected={props.connected} />
|
||||
<Show when={show()}>
|
||||
<Tips connected={connected()} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
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 <View api={api} hidden={hidden()} show={show()} connected={connected()} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
export default Plugin.define({
|
||||
id: "internal:home-tips",
|
||||
setup(context) {
|
||||
context.ui.slot("home.bottom", () => <View />)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue