Compare commits

...
30 changed files with 1175 additions and 1193 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ for (const item of targets) {
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: [
"./src/index.ts",
"./src/temporary.ts",
parserWorker,
workerPath,
rgPath,
+2 -2
View File
@@ -1,7 +1,6 @@
import z from "zod"
import type { ZodType } from "zod"
export namespace BusEvent {
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>()
@@ -30,4 +29,5 @@ export namespace BusEvent {
})
.toArray()
}
}
export * as BusEvent from "./bus-event"
+3 -5
View File
@@ -28,7 +28,7 @@ import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { LocalProvider, parseModel, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
@@ -49,10 +49,8 @@ import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
@@ -304,7 +302,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
if (route.data.type === "session") {
const session = sync.session.get(route.data.sessionID)
if (!session || SessionApi.isDefaultTitle(session.title)) {
if (!session) {
renderer.setTerminalTitle("OpenCode")
return
}
@@ -324,7 +322,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
batch(() => {
if (args.agent) local.agent.set(args.agent)
if (args.model) {
const { providerID, modelID } = Provider.parseModel(args.model)
const { providerID, modelID } = parseModel(args.model)
if (!providerID || !modelID)
return toast.show({
variant: "warning",
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json")
Filesystem.readJson(filePath)
Filesystem.readJson<Record<string, any>>(filePath)
.then((x) => {
setStore(x)
})
+1 -2
View File
@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import z from "zod"
export const TuiEvent = {
@@ -42,7 +41,7 @@ export const TuiEvent = {
SessionSelect: BusEvent.define(
"tui.session.select",
z.object({
sessionID: SessionID.zod.describe("Session ID to navigate to"),
sessionID: z.string().describe("Session ID to navigate to"),
}),
),
}
@@ -7,7 +7,7 @@ function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.lsp())
const off = createMemo(() => props.api.state.config.lsp === false)
const off = createMemo(() => props.api.state.config.lsp)
return (
<box>
@@ -16,7 +16,6 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
readPackageThemes,
readPluginId,
@@ -790,10 +789,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec)
return true
}
const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => {
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
@@ -987,9 +983,6 @@ export namespace TuiPluginRuntime {
}
runtime = next
try {
await Instance.provide({
directory: cwd,
fn: async () => {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
@@ -1021,8 +1014,6 @@ export namespace TuiPluginRuntime {
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
})
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
}
+4 -4
View File
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data
const suggestions = data?.suggestions as string[] | undefined
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
return [
`Model not found: ${data?.providerID}/${data?.modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
const data = (input as ErrorLike).data
const path = data?.path
const message = data?.message
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
}
+1 -1
View File
@@ -203,7 +203,7 @@ export const Info = z
.optional(),
lsp: z
.union([
z.literal(false),
z.literal(true),
z.record(
z.string(),
z.union([
+1 -1
View File
@@ -5,7 +5,7 @@ import os from "os"
import { Filesystem } from "@/util"
import { InvalidError } from "./error"
type ParseSource =
export type ParseSource =
| {
type: "path"
path: string
+2 -2
View File
@@ -8,7 +8,6 @@ import { ripgrep } from "ripgrep"
import { Filesystem } from "@/util"
import { Log } from "@/util"
export namespace Ripgrep {
const log = Log.create({ service: "ripgrep" })
const Stats = z.object({
@@ -572,4 +571,5 @@ export namespace Ripgrep {
)
export const defaultLayer = layer
}
export * as Ripgrep from "./ripgrep"
+2 -2
View File
@@ -5,7 +5,6 @@ import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Log } from "../util"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export type Stamp = {
@@ -110,4 +109,5 @@ export namespace FileTime {
).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
}
export * as FileTime from "./time"
+2 -2
View File
@@ -19,7 +19,6 @@ import { Log } from "../util"
declare const OPENCODE_LIBC: string | undefined
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -160,4 +159,5 @@ export namespace FileWatcher {
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
}
export * as FileWatcher from "./watcher"
+2 -2
View File
@@ -1,7 +1,6 @@
import z from "zod"
import { randomBytes } from "crypto"
export namespace Identifier {
const prefixes = {
event: "evt",
session: "ses",
@@ -83,4 +82,5 @@ export namespace Identifier {
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
}
export * as Identifier from "./id"
+5 -6
View File
@@ -167,7 +167,7 @@ export const layer = Layer.effect(
const servers: Record<string, LSPServer.Info> = {}
if (cfg.lsp === false) {
if (!cfg.lsp) {
log.info("all LSPs are disabled")
} else {
for (const server of Object.values(LSPServer)) {
@@ -440,12 +440,11 @@ export const layer = Layer.effect(
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) =>
client.connection
.sendRequest("workspace/symbol", { query })
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
.sendRequest<Symbol[]>("workspace/symbol", { query })
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
.catch(() => [] as Symbol[]),
)
return results.flat() as Symbol[]
return results.flat()
})
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
+11 -2
View File
@@ -124,8 +124,17 @@ export async function install(dir: string) {
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
type PackageDeps = Record<string, string>
type PackageJson = {
dependencies?: PackageDeps
devDependencies?: PackageDeps
peerDependencies?: PackageDeps
optionalDependencies?: PackageDeps
}
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
packages?: Record<string, PackageJson>
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
@@ -11,6 +11,11 @@ export namespace CopilotModels {
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
policy: z
.object({
state: z.string().optional(),
})
.optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
@@ -123,7 +128,9 @@ export namespace CopilotModels {
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
)
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
+4 -2
View File
@@ -547,12 +547,14 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (modelID.startsWith("duo-workflow-")) {
const workflowRef = options?.workflowRef as string | undefined
const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
const workflowDefinition =
typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
const model = sdk.workflowChat(sdkModelID, {
featureFlags,
workflowDefinition: options?.workflowDefinition as string | undefined,
workflowDefinition,
})
if (workflowRef) {
model.selectedModelRef = workflowRef
+2 -1
View File
@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { SessionID } from "@/session/schema"
const TuiRequest = z.object({
path: z.string(),
@@ -371,7 +372,7 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(SessionID.make(sessionID))))
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
+2 -2
View File
@@ -3,7 +3,6 @@ import { Bonjour } from "bonjour-service"
const log = Log.create({ service: "mdns" })
export namespace MDNS {
let bonjour: Bonjour | undefined
let currentPort: number | undefined
@@ -57,4 +56,5 @@ export namespace MDNS {
log.info("mDNS service unpublished")
}
}
}
export * as MDNS from "./mdns"
+2 -2
View File
@@ -101,7 +101,6 @@ const app = (upgrade: UpgradeWebSocket) =>
}),
)
export namespace ServerProxy {
const log = Log.Default.clone().tag("service", "server-proxy")
export async function http(
@@ -180,4 +179,5 @@ export namespace ServerProxy {
env as never,
)
}
}
export * as ServerProxy from "./proxy"
+2 -2
View File
@@ -17,7 +17,6 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors()
export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = {
@@ -124,4 +123,5 @@ export namespace Server {
},
}
}
}
export * as Server from "./server"
+4 -2
View File
@@ -272,7 +272,8 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe(
(input.usage.inputTokenDetails?.cacheWriteTokens ??
Number(
input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
@@ -281,7 +282,8 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
0,
),
)
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
+3 -26
View File
@@ -1,33 +1,10 @@
import yargs from "yargs"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { InstallationVersion } from "./installation/version"
import { hideBin } from "yargs/helpers"
import { Log } from "./node"
Log.init({
print: false,
})
const cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
.wrap(100)
.help("help", "show help")
.alias("help", "h")
.version("version", "show version number", InstallationVersion)
.alias("version", "v")
.option("print-logs", {
describe: "print logs to stderr",
type: "boolean",
})
.option("log-level", {
describe: "log level",
type: "string",
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
})
.option("pure", {
describe: "run without external plugins",
type: "boolean",
})
.command(TuiThreadCommand)
.parse()
console.log(TuiThreadCommand)
console.log(performance.now())
+1 -1
View File
@@ -19,7 +19,7 @@ export type Context<M extends Metadata = Metadata> = {
agent: string
abort: AbortSignal
callID?: string
extra?: { [key: string]: any }
extra?: { [key: string]: unknown }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
+1 -1
View File
@@ -39,7 +39,7 @@ export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8")
}
export async function readJson<T = any>(p: string): Promise<T> {
export async function readJson<T = unknown>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8"))
}
+1 -1
View File
@@ -757,7 +757,7 @@ test("updates config and writes to file", async () => {
const newConfig = { model: "updated/model" }
await save(newConfig as any)
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model")
},
})
+1 -1
View File
@@ -1601,7 +1601,7 @@ export type Config = {
}
}
lsp?:
| false
| true
| {
[key: string]:
| {
+3 -5
View File
@@ -6938,8 +6938,7 @@
"properties": {
"sessionID": {
"description": "Session ID to navigate to",
"type": "string",
"pattern": "^ses.*"
"type": "string"
}
},
"required": ["sessionID"]
@@ -8511,8 +8510,7 @@
"properties": {
"sessionID": {
"description": "Session ID to navigate to",
"type": "string",
"pattern": "^ses.*"
"type": "string"
}
},
"required": ["sessionID"]
@@ -11761,7 +11759,7 @@
"anyOf": [
{
"type": "boolean",
"const": false
"const": true
},
{
"type": "object",