mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 23:46:16 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc85be5e5 | ||
|
|
fa6e7e179d | ||
|
|
4272bb83e0 | ||
|
|
1ae2cf3567 | ||
|
|
2084c52952 | ||
|
|
5a9931280e | ||
|
|
b2fb2c5e36 | ||
|
|
43bd2a516b | ||
|
|
dad7688739 | ||
|
|
961b8ccb86 |
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-plus",
|
||||
)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const openFileTab = panel.getByRole("tab", { name: "Open file" })
|
||||
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
|
||||
await expect(openFileTab).toHaveAttribute("data-selected", "")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
|
||||
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
|
||||
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
|
||||
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
|
||||
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
|
||||
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
|
||||
when={!props.placeholder}
|
||||
fallback={
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" />
|
||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ export function SessionSidePanel(props: {
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" />}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
|
||||
@@ -59,6 +59,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
|
||||
@@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -83,11 +82,14 @@ export default Runtime.handler(Commands, (input) =>
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: service
|
||||
? {
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
}
|
||||
: undefined,
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
|
||||
@@ -67,9 +67,13 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
|
||||
function add(node: Spec.Any, value: RuntimeHandlers) {
|
||||
if (typeof value === "function") {
|
||||
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
return
|
||||
}
|
||||
if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
if (value.$) {
|
||||
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
}
|
||||
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
|
||||
}
|
||||
|
||||
@@ -99,8 +103,12 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
)
|
||||
: node.spec
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
const children = Object.values(node.commands)
|
||||
return spec.pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
Command.withSubcommands([
|
||||
...children.map((child) => provide(child, handlers)),
|
||||
...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))),
|
||||
]),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"
|
||||
|
||||
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
|
||||
readonly description?: string
|
||||
readonly aliases?: ReadonlyArray<string>
|
||||
readonly params?: Config
|
||||
readonly commands?: Commands
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export interface Node<
|
||||
readonly name: Name
|
||||
readonly spec: Spec
|
||||
readonly commands: Commands
|
||||
readonly aliases: ReadonlyArray<Any>
|
||||
}
|
||||
|
||||
export type Any = Node<string, Command.Command<any, any, any, any, any>, Children>
|
||||
@@ -24,14 +26,28 @@ export function make<
|
||||
const Config extends Command.Command.Config = {},
|
||||
const Commands extends ReadonlyArray<Any> = [],
|
||||
>(name: Name, options: Options<Config, Commands> = {}) {
|
||||
const command = Command.make(name, options.params ?? ({} as Config))
|
||||
const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
const aliases = options.aliases ?? []
|
||||
const params = options.params ?? ({} as Config)
|
||||
const command = Command.make(name, params)
|
||||
const described = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
// Effect supports a single native alias, shown inline as `name, alias` in help.
|
||||
// Extra aliases become sibling commands sharing params and subcommands.
|
||||
const spec = aliases.length > 0 ? described.pipe(Command.withAlias(aliases[0])) : described
|
||||
const commands = Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>
|
||||
const extra = aliases.slice(1).map((alias) => {
|
||||
const aliasCommand = Command.make(alias, params)
|
||||
const aliasSpec = options.description
|
||||
? aliasCommand.pipe(Command.withDescription(options.description))
|
||||
: aliasCommand
|
||||
return { name: alias, spec: aliasSpec, commands, aliases: [] }
|
||||
})
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
commands: Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>,
|
||||
commands,
|
||||
aliases: extra,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,12 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -29,7 +27,6 @@ export type Options = {
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
replacements: [
|
||||
@@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
|
||||
const next = yield* Effect.scoped(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
@@ -66,7 +62,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
if (incumbent !== undefined) return
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
if (server === undefined) return
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater
|
||||
.monitor({
|
||||
url,
|
||||
password,
|
||||
managed: options.mode === "service",
|
||||
notify: server.updateAvailable,
|
||||
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
? Effect.raceFirst(
|
||||
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
|
||||
Deferred.await(replacement).pipe(Effect.map(Option.some)),
|
||||
)
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
|
||||
? waitForStdinClose()
|
||||
: Effect.never
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
if (Option.isNone(next)) return
|
||||
yield* spawnReplacement(next.value)
|
||||
})
|
||||
|
||||
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const [command, ...args] = options.command
|
||||
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
|
||||
// We do not monitor the replacement after spawn. A managed TUI
|
||||
// recovers with Service.ensure if startup fails; a future client
|
||||
// restart signal could coordinate that recovery instead.
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
|
||||
},
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => new Error("Failed to start replacement server", { cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "upgrade"
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,10 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
if (policy === "notify") return "notify"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "notify"
|
||||
return "upgrade"
|
||||
return "notify"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,22 +6,17 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("reports patches and minors without automatically installing them", () => {
|
||||
test("reports every available release", () => {
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
|
||||
@@ -32,25 +27,21 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
test("reports majors instead of automatically installing them", () => {
|
||||
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
|
||||
test("reports when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
|
||||
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
@@ -71,21 +62,21 @@ describe("updater", () => {
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,154 +1,36 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export type Inspection =
|
||||
| { readonly action: "none" }
|
||||
| { readonly action: Exclude<Action, "none">; readonly version: string }
|
||||
|
||||
type State =
|
||||
| { readonly type: "current" }
|
||||
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
|
||||
| { readonly type: "ready-to-restart"; readonly version: string }
|
||||
|
||||
export interface MonitorInput {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly inspect: () => Effect.Effect<Inspection, Error>
|
||||
readonly install: (version: string) => Effect.Effect<boolean, Error>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
readonly interval?: Duration.Input
|
||||
readonly notificationThreshold?: Duration.Input
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
|
||||
const state = yield* Ref.make<State>({ type: "current" })
|
||||
const applyLock = yield* Semaphore.make(1)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: input.url,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
|
||||
})
|
||||
|
||||
const applyIfIdle = () =>
|
||||
applyLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Ref.get(state)
|
||||
if (pending.type !== "available") return
|
||||
const active = yield* Effect.tryPromise({
|
||||
try: () => client.session.active(),
|
||||
catch: (cause) => new Error("Failed to read active sessions", { cause }),
|
||||
})
|
||||
if (Object.keys(active).length > 0) return
|
||||
const latest = yield* input.inspect()
|
||||
if (latest.action !== "upgrade") {
|
||||
yield* Ref.set(state, { type: "current" })
|
||||
return
|
||||
}
|
||||
const installed = yield* input
|
||||
.install(latest.version)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!installed) return
|
||||
const handoff = input.managed
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => client.experimental.persistentPty.handoff(),
|
||||
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
|
||||
})
|
||||
: undefined
|
||||
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
|
||||
if (handoff) yield* input.restart(handoff.handoff)
|
||||
}),
|
||||
)
|
||||
|
||||
const checkServer = Effect.gen(function* () {
|
||||
const result = yield* input.inspect()
|
||||
if (result.action === "notify") {
|
||||
yield* input.notify(result.version)
|
||||
return
|
||||
}
|
||||
if (result.action !== "upgrade") {
|
||||
yield* Ref.update(
|
||||
state,
|
||||
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* Ref.update(state, (current): State => {
|
||||
if (current.type === "ready-to-restart" && current.version === result.version) return current
|
||||
return {
|
||||
type: "available",
|
||||
version: result.version,
|
||||
availableSince: current.type === "available" ? current.availableSince : Date.now(),
|
||||
}
|
||||
})
|
||||
yield* applyIfIdle()
|
||||
const pending = yield* Ref.get(state)
|
||||
if (
|
||||
pending.type === "available" &&
|
||||
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
|
||||
)
|
||||
yield* input.notify(pending.version)
|
||||
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
|
||||
|
||||
const subscribe = Effect.suspend(() =>
|
||||
Stream.fromAsyncIterable(
|
||||
client.event.subscribe(),
|
||||
(cause) => new Error("Update event stream failed", { cause }),
|
||||
).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return applyIfIdle()
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.failed" &&
|
||||
event.type !== "session.execution.interrupted"
|
||||
)
|
||||
return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: () => client.session.wait({ sessionID: event.data.sessionID }),
|
||||
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
|
||||
}).pipe(Effect.andThen(applyIfIdle()))
|
||||
}),
|
||||
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
|
||||
),
|
||||
).pipe(Effect.repeat(Schedule.spaced("1 second")))
|
||||
|
||||
return yield* Effect.all(
|
||||
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -161,13 +43,14 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
@@ -192,7 +75,7 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
@@ -302,19 +185,19 @@ const make = Effect.gen(function* () {
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
|
||||
const inspect = Effect.fnUntraced(function* () {
|
||||
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
|
||||
yield* Effect.logInfo("update check skipped", {
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
})
|
||||
return { action: "none" }
|
||||
return undefined
|
||||
}
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === "disable") {
|
||||
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
return { action: "none" }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const version = yield* latest()
|
||||
@@ -325,19 +208,16 @@ const make = Effect.gen(function* () {
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return { action: "none" }
|
||||
return undefined
|
||||
}
|
||||
if (next === "notify") {
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return { action: next, version }
|
||||
}
|
||||
return { action: next, version }
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
const detected = yield* method()
|
||||
if (!detected) {
|
||||
yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
yield* upgrade(detected, version)
|
||||
@@ -349,26 +229,9 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (result.action !== "upgrade") return
|
||||
yield* install(result.version)
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
|
||||
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) {
|
||||
return yield* monitorServer({ ...input, inspect, install })
|
||||
})
|
||||
|
||||
return Service.of({ check, monitor, apply, method, latest, upgrade })
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,9 +12,8 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
record("method")
|
||||
|
||||
@@ -1,107 +1,40 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.live("installs and restarts after the final Session settles", () =>
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
|
||||
const installed = yield* Deferred.make<string>()
|
||||
const restarted = yield* Deferred.make<void>()
|
||||
yield* Updater.monitorServer({
|
||||
url: fixture.url,
|
||||
password: "test",
|
||||
managed: true,
|
||||
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
|
||||
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
|
||||
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
|
||||
notify: () => Effect.void,
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
|
||||
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
|
||||
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
|
||||
|
||||
fixture.settle()
|
||||
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
|
||||
expect(
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(installed),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
|
||||
),
|
||||
).toBe("1.1.0")
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(restarted),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
const wait = (promise: Promise<unknown>, message: () => string) =>
|
||||
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
function makeServer() {
|
||||
const encoder = new TextEncoder()
|
||||
const activeRead = Promise.withResolvers<void>()
|
||||
const eventOpened = Promise.withResolvers<void>()
|
||||
const waited = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/session/active") {
|
||||
activeRead.resolve()
|
||||
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
|
||||
waited.resolve()
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
|
||||
return Response.json({ handoff: null })
|
||||
}
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
eventOpened.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: server.url.origin,
|
||||
activeRead: activeRead.promise,
|
||||
eventOpened: eventOpened.promise,
|
||||
waited: waited.promise,
|
||||
settle() {
|
||||
active = false
|
||||
events?.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_settled",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
|
||||
data: { sessionID: "ses_test" },
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
events?.close()
|
||||
events = undefined
|
||||
},
|
||||
stop() {
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1892,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify" | "auto"
|
||||
update?: "disable" | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
|
||||
|
||||
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
|
||||
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
|
||||
@@ -73,6 +73,11 @@ export function normalize(input: unknown): Result {
|
||||
const legacyUpdate = own(input, "autoupdate")
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
@@ -86,7 +91,10 @@ export function normalize(input: unknown): Result {
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
const migratedUpdate =
|
||||
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
|
||||
if (update !== undefined) encoded.update = update
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
@@ -196,7 +204,6 @@ export function normalize(input: unknown): Result {
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
update: Info.fields.update,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
|
||||
@@ -18,7 +18,9 @@ const RemoteModel = Schema.Struct({
|
||||
Schema.Struct({
|
||||
batch_size: Schema.Number,
|
||||
default: Schema.Struct({
|
||||
cache_price: Schema.Number,
|
||||
// API version 2026-08-01 renamed cache_price to cache_read_price.
|
||||
cache_price: Schema.optional(Schema.Number),
|
||||
cache_read_price: Schema.optional(Schema.Number),
|
||||
input_price: Schema.Number,
|
||||
output_price: Schema.Number,
|
||||
}),
|
||||
@@ -166,7 +168,9 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
|
||||
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
|
||||
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
|
||||
read: Money.USDPerMillionTokens.make(
|
||||
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
|
||||
),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,7 +55,6 @@ type Active = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
recovery?: Recovery
|
||||
@@ -77,7 +76,7 @@ type BackgroundResult = {
|
||||
backgrounded?: Deferred.Deferred<Info>
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
|
||||
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
@@ -184,14 +183,14 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.scope !== scope) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
@@ -241,7 +240,6 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
@@ -255,18 +253,17 @@ export const make = Effect.gen(function* () {
|
||||
done,
|
||||
backgrounded,
|
||||
scope,
|
||||
token,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.token, exit)),
|
||||
Effect.flatMap((exit) => settle(id, result.scope, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
export * as ModalModels from "./models.js"
|
||||
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
const ReasoningOption = Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
values: Schema.Array(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
base_model_id: Schema.optional(Schema.String),
|
||||
hugging_face_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
input_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
output_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
context_length: Schema.optional(Schema.Number),
|
||||
max_output_length: Schema.optional(Schema.Number),
|
||||
pricing: Schema.optional(
|
||||
Schema.Struct({
|
||||
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
}),
|
||||
),
|
||||
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
|
||||
supported_features: Schema.optional(Schema.Array(Schema.String)),
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Boolean,
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
|
||||
const decodeResponse = Schema.decodeUnknownSync(Response)
|
||||
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
|
||||
|
||||
type RemoteModel = typeof RemoteModel.Type
|
||||
|
||||
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
|
||||
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
|
||||
|
||||
// Decode each item tolerantly so one malformed entry cannot discard the
|
||||
// whole inventory. A malformed envelope still fails the fetch.
|
||||
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
|
||||
const model = Option.getOrUndefined(decodeModel(raw))
|
||||
return model ? [model] : []
|
||||
})
|
||||
const templates = new Map(existing.map((model) => [model.id, model]))
|
||||
const result = new Map<Model.ID, Model.Info>()
|
||||
for (const item of remote) {
|
||||
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
|
||||
const id = Model.ID.make(item.id)
|
||||
result.set(id, build(id, item, baseURL, template))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
|
||||
if (value === undefined) return fallback
|
||||
const parsed = Number(value) * 1_000_000
|
||||
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
|
||||
}
|
||||
|
||||
function limit(value: number | undefined, fallback: number) {
|
||||
const parsed = value === undefined ? fallback : Math.trunc(value)
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
|
||||
const cost = previous?.cost[0]
|
||||
const input = previous?.limit.input
|
||||
return Model.Info.make({
|
||||
...Model.Info.default(providerID, id),
|
||||
id,
|
||||
modelID: Model.ID.make(remote.id),
|
||||
providerID,
|
||||
name: remote.name ?? previous?.name ?? remote.id,
|
||||
family: previous?.family,
|
||||
compatibility:
|
||||
remote.interleaved === undefined
|
||||
? previous?.compatibility
|
||||
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
|
||||
headers: previous?.headers,
|
||||
body: previous?.body,
|
||||
capabilities: {
|
||||
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
|
||||
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
|
||||
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
|
||||
},
|
||||
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
|
||||
time: previous?.time ?? { released: 0 },
|
||||
cost: [
|
||||
{
|
||||
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
|
||||
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
|
||||
cache: {
|
||||
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
|
||||
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
],
|
||||
status: previous?.status ?? "active",
|
||||
enabled: previous?.enabled ?? true,
|
||||
limit: {
|
||||
context: limit(remote.context_length, previous?.limit.context ?? 0),
|
||||
...(input === undefined ? {} : { input }),
|
||||
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function variants(remote: RemoteModel): Model.Info["variants"] {
|
||||
const seen = new Map<string, Model.Info["variants"][number]>()
|
||||
for (const option of remote.reasoning_options ?? []) {
|
||||
for (const value of option.values) {
|
||||
const effort = value ?? "none"
|
||||
if (!seen.has(effort))
|
||||
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
|
||||
}
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
import { MistralPlugin } from "./provider/mistral.js"
|
||||
import { ModalPlugin } from "./provider/modal.js"
|
||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||
import { OllamaPlugin } from "./provider/ollama.js"
|
||||
import { OpenAIPlugin } from "./provider/openai.js"
|
||||
@@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
LLMGatewayPlugin,
|
||||
LMStudioPlugin,
|
||||
MistralPlugin,
|
||||
ModalPlugin,
|
||||
NvidiaPlugin,
|
||||
OllamaPlugin,
|
||||
OpencodePlugin,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
const apiVersion = "2026-06-01"
|
||||
const apiVersion = "2026-08-01"
|
||||
const userApiVersion = "2025-04-01"
|
||||
const pollingSafetyMargin = 3000
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
@@ -250,15 +250,26 @@ export const GithubCopilotPlugin = define({
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
)
|
||||
// Runs for every route, unlike http.request, which the AI SDK route bypasses.
|
||||
yield* ctx.session.hook(
|
||||
"model.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
const session = yield* ctx.session
|
||||
.get({ sessionID: evt.sessionID })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.agent, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (evt.agent === Agent.ID.make("title"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||
if (evt.agent === Agent.ID.make("compaction"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||
const token = evt.request.headers.get("x-api-key")
|
||||
if (!token) return
|
||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||
@@ -370,11 +381,23 @@ function applyHeaders(
|
||||
headers.set("User-Agent", App.useragent(app))
|
||||
headers.set("Openai-Intent", "conversation-edits")
|
||||
headers.set("X-GitHub-Api-Version", apiVersion)
|
||||
headers.set("x-initiator", metadata.agent ? "agent" : "user")
|
||||
// The step may already have declared itself agent-initiated (subagent, title, compaction);
|
||||
// the body can only ever escalate to "agent", never back to "user".
|
||||
if (metadata.agent) headers.set("x-initiator", "agent")
|
||||
else if (!headers.has("x-initiator")) headers.set("x-initiator", "user")
|
||||
if (metadata.vision) headers.set("Copilot-Vision-Request", "true")
|
||||
if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
||||
}
|
||||
|
||||
// Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default,
|
||||
// nested sessions are subagents, and title/compaction are the two utility overrides.
|
||||
export function interactionType(agent: Agent.ID, child: boolean) {
|
||||
if (agent === Agent.ID.make("title")) return "conversation-background"
|
||||
if (agent === Agent.ID.make("compaction")) return "conversation-compaction"
|
||||
if (child) return "conversation-subagent"
|
||||
return "conversation-agent"
|
||||
}
|
||||
|
||||
type RequestMetadata = ReturnType<typeof requestMetadata>
|
||||
|
||||
function requestMetadata(url: string, body: unknown) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Effect, Semaphore, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { ModalModels } from "../../modal/models.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
export const ModalPlugin = define({
|
||||
id: "opencode.provider.modal",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: {
|
||||
baseURL?: string
|
||||
models?: Map<Model.ID, Model.Info>
|
||||
} = {}
|
||||
|
||||
const load = Effect.fn("ModalPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("modal")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
|
||||
if (!apiKey || !baseURL) {
|
||||
loaded.baseURL = undefined
|
||||
loaded.models = undefined
|
||||
return
|
||||
}
|
||||
loaded.baseURL = baseURL
|
||||
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
|
||||
loaded.models = yield* Effect.tryPromise({
|
||||
try: () => ModalModels.get(baseURL, apiKey, existing),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
if (!loaded.models) return
|
||||
for (const id of item.models.keys()) {
|
||||
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
|
||||
}
|
||||
for (const [id, model] of loaded.models) {
|
||||
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -28,11 +28,9 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
const metric = (event: string, attributes: Record<string, string> = {}) =>
|
||||
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
|
||||
|
||||
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
|
||||
|
||||
interface Active {
|
||||
readonly queue: Queue.Queue<string, AIError>
|
||||
readonly lifecycle: { delivery: Delivery }
|
||||
delivery: "send-attempted" | "provider-observed" | "terminal"
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
@@ -130,14 +128,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "close",
|
||||
phase: "close",
|
||||
delivery:
|
||||
channel.active.lifecycle.delivery === "queued" ||
|
||||
channel.active.lifecycle.delivery === "connecting" ||
|
||||
channel.active.lifecycle.delivery === "ready"
|
||||
? "not-sent"
|
||||
: channel.active.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active.lifecycle.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -198,7 +191,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
})
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
active.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
@@ -226,8 +219,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
channel.active?.delivery === "provider-observed" ||
|
||||
channel.active?.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
@@ -256,7 +249,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -288,7 +280,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* closeChannel(owner, current)
|
||||
}
|
||||
|
||||
lifecycle.delivery = owner.channel ? "ready" : "connecting"
|
||||
if (owner.channel)
|
||||
yield* Effect.logDebug("session websocket reused", {
|
||||
sessionTransport: "websocket",
|
||||
@@ -314,7 +305,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
lifecycle.delivery = "ready"
|
||||
|
||||
if (channel.pending) {
|
||||
channel.pending = undefined
|
||||
@@ -326,9 +316,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
|
||||
const active: Active = {
|
||||
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
lifecycle.delivery = "send-attempted"
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
@@ -366,7 +358,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -375,7 +367,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.sync(() => {
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
lifecycle.delivery = "terminal"
|
||||
active.delivery = "terminal"
|
||||
const staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
@@ -411,7 +403,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
})
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
@@ -448,7 +440,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
return Effect.succeed({
|
||||
get http() {
|
||||
@@ -456,7 +447,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.andThen(start(owner, exchange)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
? "notify"
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
|
||||
expect(initialized.text).toContain(
|
||||
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
|
||||
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
|
||||
)
|
||||
expect(initialized.text).toContain("## Available tools")
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -665,10 +666,18 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
|
||||
@@ -118,3 +118,40 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("prices cache reads from either token price spelling", async () => {
|
||||
// API version 2026-08-01 renamed cache_price to cache_read_price; older payloads still use cache_price.
|
||||
const item = (id: string, prices: Record<string, number>) => ({
|
||||
model_picker_enabled: true,
|
||||
id,
|
||||
name: id,
|
||||
version: `${id}-2026-08-01`,
|
||||
supported_endpoints: ["/chat/completions"],
|
||||
billing: { token_prices: { batch_size: 1_000_000, default: { input_price: 250, output_price: 1500, ...prices } } },
|
||||
capabilities: {
|
||||
family: "gpt",
|
||||
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
|
||||
supports: { tool_calls: true },
|
||||
},
|
||||
})
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
data: [
|
||||
item("renamed", { cache_read_price: 25, cache_write_price: 0 }),
|
||||
item("legacy", { cache_price: 25 }),
|
||||
item("unpriced", {}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
const models = await CopilotModels.get(server.url.origin, {}, [])
|
||||
expect(models.get(Model.ID.make("renamed"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
|
||||
expect(models.get(Model.ID.make("legacy"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
|
||||
expect(models.get(Model.ID.make("unpriced"))?.cost[0]).toMatchObject({ cache: { read: 0 } })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,6 +64,71 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reuses running work when started again with the same ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const output = yield* Deferred.make<string>()
|
||||
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
|
||||
|
||||
expect(
|
||||
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
|
||||
).toEqual(job)
|
||||
|
||||
yield* Deferred.succeed(output, "original output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "test",
|
||||
status: "completed",
|
||||
output: "original output",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const callback = yield* Deferred.make<() => void>()
|
||||
const output = yield* Deferred.make<string>()
|
||||
const finalized = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "job_replaced",
|
||||
type: "test",
|
||||
run: Effect.callback<string>((resume) => {
|
||||
Deferred.doneUnsafe(
|
||||
callback,
|
||||
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const complete = yield* Deferred.await(callback)
|
||||
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
|
||||
const replacement = yield* jobs.wait({ id: job.id }).pipe(
|
||||
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
|
||||
Effect.andThen(
|
||||
jobs.start({
|
||||
id: job.id,
|
||||
type: "replacement",
|
||||
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.sync(complete)),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* jobs.cancel(job.id)
|
||||
yield* Fiber.join(replacement)
|
||||
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(output, "replacement output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "replacement",
|
||||
status: "completed",
|
||||
output: "replacement output",
|
||||
})
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns finished from a blocking wait when completion wins", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { ModalModels } from "@opencode-ai/core/modal/models"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
test("modal plugin is registered", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal")
|
||||
})
|
||||
|
||||
function template(id: string, overrides: Partial<Model.Info> = {}) {
|
||||
return Model.Info.make({
|
||||
...Model.Info.default(providerID, Model.ID.make(id)),
|
||||
name: `${id} catalog`,
|
||||
family: Model.Family.make("catalog-family"),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
test("maps live Modal models onto catalog templates", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer test-key")
|
||||
expect(new URL(request.url).pathname).toBe("/v1/models")
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "live-model",
|
||||
base_model_id: "base-model",
|
||||
name: "Live Model",
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
context_length: 128000,
|
||||
max_output_length: 8192,
|
||||
pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" },
|
||||
supported_sampling_parameters: ["temperature"],
|
||||
supported_features: ["tools", "reasoning"],
|
||||
reasoning_options: [{ type: "effort", values: ["low", "high", null] }],
|
||||
interleaved: { field: "reasoning_content" },
|
||||
},
|
||||
{
|
||||
id: "standalone",
|
||||
context_length: 64000,
|
||||
},
|
||||
{ id: "malformed", context_length: "huge" },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const base = template("base-model")
|
||||
const stale = template("stale")
|
||||
const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale])
|
||||
|
||||
expect(models.has(Model.ID.make("stale"))).toBe(false)
|
||||
expect(models.has(Model.ID.make("malformed"))).toBe(false)
|
||||
|
||||
const model = models.get(Model.ID.make("live-model"))
|
||||
expect(model?.name).toBe("Live Model")
|
||||
expect(model?.family).toBe(Model.Family.make("catalog-family"))
|
||||
expect(model?.providerID).toBe(providerID)
|
||||
expect(model?.modelID).toBe(Model.ID.make("live-model"))
|
||||
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
|
||||
expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` })
|
||||
expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" })
|
||||
expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] })
|
||||
expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1))
|
||||
expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2))
|
||||
expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10)
|
||||
expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero)
|
||||
expect(model?.limit).toMatchObject({ context: 128000, output: 8192 })
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
Model.VariantID.make("low"),
|
||||
Model.VariantID.make("high"),
|
||||
Model.VariantID.make("none"),
|
||||
])
|
||||
expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" })
|
||||
expect(model?.status).toBe("active")
|
||||
|
||||
const fresh = models.get(Model.ID.make("standalone"))
|
||||
expect(fresh?.name).toBe("standalone")
|
||||
expect(fresh?.family).toBeUndefined()
|
||||
expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(fresh?.variants).toEqual([])
|
||||
expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps template cost and limits when the proxy omits them", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
data: [{ id: "sparse", hugging_face_id: "hf-base" }],
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
const base = template("hf-base", {
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(10),
|
||||
cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) },
|
||||
},
|
||||
],
|
||||
limit: { context: 1000, input: 500, output: 250 },
|
||||
})
|
||||
const models = await ModalModels.get(server.url.origin, "test-key", [base])
|
||||
const model = models.get(Model.ID.make("sparse"))
|
||||
expect(model?.name).toBe("hf-base catalog")
|
||||
expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } })
|
||||
expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("throws on proxy failure so the plugin can fail soft", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response("nope", { status: 500 }),
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow()
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -35,6 +36,24 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const sessions = Effect.fn(function* () {
|
||||
const service = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const parent = yield* service.create({ location: { directory: location.directory } })
|
||||
const child = yield* service.create({ parentID: parent.id })
|
||||
return { parent: parent.id, child: child.id }
|
||||
})
|
||||
|
||||
const modelRequest = Effect.fn(function* (sessionID: Session.ID, agent: string) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return yield* hooks.trigger("session", "model.request", {
|
||||
sessionID,
|
||||
agent: Agent.ID.make(agent),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
headers: {},
|
||||
})
|
||||
})
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
test("prefers the account-specific Copilot API endpoint", () => {
|
||||
expect(
|
||||
@@ -122,7 +141,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
expect(requests[0]?.has("x-api-key")).toBe(false)
|
||||
expect(requests[0]?.get("x-initiator")).toBe("user")
|
||||
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
|
||||
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
|
||||
expect(requests[0]?.get("x-github-api-version")).toBe("2026-08-01")
|
||||
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
|
||||
}),
|
||||
)
|
||||
@@ -145,35 +164,75 @@ describe("GithubCopilotPlugin", () => {
|
||||
expect(event.request.headers.has("x-api-key")).toBe(false)
|
||||
expect(event.request.headers.get("x-initiator")).toBe("user")
|
||||
expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
|
||||
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
|
||||
expect(event.request.headers.get("x-github-api-version")).toBe("2026-08-01")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies main-loop steps as agent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "build")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies child-session steps as subagent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "build")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_title"),
|
||||
agent: Agent.ID.make("title"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
|
||||
request: new Request("https://api.githubcopilot.com/chat/completions"),
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "title")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores other providers' model requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_compaction"),
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
request: new Request("https://api.githubcopilot.com/responses"),
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
sessionID: (yield* sessions()).parent,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5.4") }),
|
||||
headers: {},
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
|
||||
expect(event.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps a declared agent initiator when the body looks user-initiated", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Headers[] = []
|
||||
const send = copilotFetch(
|
||||
"token",
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
requests.push(new Headers(init?.headers))
|
||||
return Response.json({ ok: true })
|
||||
},
|
||||
App.make({ name: "test", version: "1.2.3", channel: "beta" }),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
send("https://api.githubcopilot.com/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "x-initiator": "agent" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: "summarize" }] }),
|
||||
}),
|
||||
)
|
||||
expect(requests[0]?.get("x-initiator")).toBe("agent")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -445,14 +445,17 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes an active exchange without waiting for its Session permit", async () => {
|
||||
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Deferred.succeed(started, undefined),
|
||||
sendText: () =>
|
||||
observed
|
||||
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
|
||||
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
@@ -462,17 +465,30 @@ describe("SessionModelTransport", () => {
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
|
||||
const executor = transport.bind(session)
|
||||
const item = exchange("active")
|
||||
const running = yield* collect(executor, {
|
||||
...item,
|
||||
driver: {
|
||||
...item.driver,
|
||||
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
|
||||
},
|
||||
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const queued = yield* collect(executor, exchange("queued")).pipe(
|
||||
Effect.result,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* transport.close(session)
|
||||
const result = yield* Effect.result(Fiber.join(running))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
|
||||
})
|
||||
expect(yield* Fiber.join(queued)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
@@ -545,6 +561,43 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a connection returned after its owner closes during setup", async () => {
|
||||
const connecting = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Deferred.succeed(connecting, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({
|
||||
sendText: () => Effect.die("Unexpected send after owner close"),
|
||||
messages: Stream.never,
|
||||
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(
|
||||
transport.bind(session),
|
||||
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
|
||||
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(connecting)
|
||||
yield* transport.close(session)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back once when connection setup fails before send", async () => {
|
||||
let fallbacks = 0
|
||||
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
|
||||
|
||||
@@ -60,3 +60,10 @@ describe("electron vite publicDir", () => {
|
||||
expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("renders before loading optional telemetry", async () => {
|
||||
const source = await Bun.file(join(dir, "index.tsx")).text()
|
||||
expect(source.indexOf("render(() =>")).toBeGreaterThan(-1)
|
||||
expect(source.indexOf("render(() =>")).toBeLessThan(source.indexOf("initializeSentry(version)"))
|
||||
expect(source).not.toContain("await initializeSentry")
|
||||
})
|
||||
|
||||
@@ -13,10 +13,10 @@ import { desktopVersion, initializeSentry } from "./startup/sentry"
|
||||
|
||||
const root = requireRendererRoot()
|
||||
const version = desktopVersion()
|
||||
await initializeSentry(version)
|
||||
|
||||
const updater = startDesktopUpdater(api)
|
||||
startDesktopMenu(api)
|
||||
startDeepLinks(api)
|
||||
|
||||
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
|
||||
void initializeSentry(version)
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install automatically",
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
@@ -116,12 +114,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from "bun:test"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
@@ -100,12 +99,9 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
expect(event.status).toBe(200)
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
const reader = event.body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -130,11 +126,3 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
padding-bottom: 160px;
|
||||
text-align: center;
|
||||
@@ -502,25 +502,26 @@
|
||||
|
||||
[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] {
|
||||
flex: none;
|
||||
margin-bottom: 8px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-empty-changes-title"] {
|
||||
flex: none;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 13px;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-empty-changes-description"] {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
max-width: 282px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
line-height: var(--line-height-base);
|
||||
text-align: center;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
+31
-16
@@ -186,6 +186,7 @@ export type TuiInput = {
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageSource
|
||||
@@ -220,9 +221,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
// Give the server a chance to respawn itself before starting client-side recovery.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next), url: endpoint.url }
|
||||
@@ -507,6 +505,36 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -1215,19 +1243,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", (evt) => {
|
||||
const updater = props.updater
|
||||
const restart = client.restart
|
||||
if (!updater || !restart) return
|
||||
const version = evt.data.version
|
||||
if (updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
event.on("tui.session.select", (evt, { workspace }) => {
|
||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||
route.navigate({
|
||||
|
||||
@@ -8,22 +8,32 @@ import { useDialog } from "../ui/dialog"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "ignore" }
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
|
||||
export function DialogUpdate(props: {
|
||||
dialogKey: string
|
||||
version: string
|
||||
install: () => Promise<void>
|
||||
restart?: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
dialog.clear()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const beginInstall = () => {
|
||||
@@ -34,16 +44,16 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
const run = () => {
|
||||
const current = state()
|
||||
if (current.type !== "ready") return
|
||||
if (current.active === "ignore") return dialog.clear()
|
||||
if (current.active === "skip") return close()
|
||||
beginInstall()
|
||||
}
|
||||
|
||||
const toggle = () =>
|
||||
setState((current) =>
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
|
||||
)
|
||||
|
||||
const selected = (action: "update" | "ignore") => {
|
||||
const selected = (action: "update" | "skip") => {
|
||||
const current = state()
|
||||
return current.type === "ready" && current.active === action
|
||||
}
|
||||
@@ -60,7 +70,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => (state().type === "failed" ? dialog.clear() : run()),
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
},
|
||||
{
|
||||
bind: "left",
|
||||
@@ -81,9 +91,9 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Update
|
||||
Update available
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -91,14 +101,17 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
<Switch>
|
||||
<Match when={state().type === "ready"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
|
||||
An update is available. Applying will
|
||||
{props.restart
|
||||
? " restart the server and active sessions will be resumed."
|
||||
: " install the update but you will need to manually restart."}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={state().type === "installing"}>
|
||||
<Spinner>Installing OpenCode {props.version}…</Spinner>
|
||||
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "restarting"}>
|
||||
<Spinner>Restarting the background service…</Spinner>
|
||||
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>{failure()}</text>
|
||||
@@ -114,7 +127,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
onMouseUp={close}
|
||||
>
|
||||
<text fg={theme.text.action.primary.focused}>close</text>
|
||||
</box>
|
||||
@@ -123,19 +136,19 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["ignore", "update"] as const}>
|
||||
<For each={["skip", "update"] as const}>
|
||||
{(action) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "ignore") return dialog.clear()
|
||||
if (action === "skip") return close()
|
||||
beginInstall()
|
||||
}}
|
||||
>
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Ignore"}
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
OptimizedBuffer,
|
||||
RGBA,
|
||||
TargetChannel,
|
||||
TextRenderable,
|
||||
type RenderContext,
|
||||
type TextOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, type JSX } from "@opentui/solid"
|
||||
import { splitProps } from "solid-js"
|
||||
import { coast, intensityAt } from "./tab-pulse"
|
||||
|
||||
type ShimmerTextOptions = TextOptions & {
|
||||
shimmer: RGBA
|
||||
}
|
||||
|
||||
const DURATION = 1200
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
class ShimmerTextRenderable extends TextRenderable {
|
||||
private _shimmer = RGBA.defaultForeground()
|
||||
private elapsed = 0
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
private matrix = new Float32Array(16)
|
||||
|
||||
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
|
||||
super(ctx, options)
|
||||
this.matrix[3] = this._shimmer.r
|
||||
this.matrix[7] = this._shimmer.g
|
||||
this.matrix[11] = this._shimmer.b
|
||||
this.matrix[15] = 1
|
||||
if (options.shimmer) this.shimmer = options.shimmer
|
||||
this.live = true
|
||||
}
|
||||
|
||||
set shimmer(value: RGBA) {
|
||||
if (value.equals(this._shimmer)) return
|
||||
this._shimmer = value
|
||||
this.matrix[3] = value.r
|
||||
this.matrix[7] = value.g
|
||||
this.matrix[11] = value.b
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
override render(buffer: OptimizedBuffer, deltaTime: number) {
|
||||
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
|
||||
this.elapsed = (this.elapsed + deltaTime) % DURATION
|
||||
if (!this.scratch)
|
||||
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
|
||||
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
|
||||
this.scratch.resize(this.width, this.height)
|
||||
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
const characters = this.scratch.buffers.char
|
||||
let end = 0
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
let column = this.width
|
||||
while (
|
||||
column > 0 &&
|
||||
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
|
||||
)
|
||||
column--
|
||||
end = Math.max(end, column)
|
||||
}
|
||||
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
|
||||
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
|
||||
let strength = 0
|
||||
for (let cell = 0; cell < characters.length; cell++) {
|
||||
const column = cell % this.width
|
||||
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18)
|
||||
this.mask[cell * 3] = column
|
||||
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
|
||||
this.mask[cell * 3 + 2] = strength
|
||||
}
|
||||
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
|
||||
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
|
||||
this.markClean()
|
||||
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.scratch?.destroy()
|
||||
this.scratch = undefined
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
extend({ shimmer_text: ShimmerTextRenderable })
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
shimmer_text: typeof ShimmerTextRenderable
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
|
||||
|
||||
export function ShimmerText(props: Props) {
|
||||
const [local, text] = splitProps(props, ["shimmer"])
|
||||
return <shimmer_text {...text} shimmer={local.shimmer} />
|
||||
}
|
||||
@@ -1,30 +1,48 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
import { SPINNER_FRAMES } from "./spinner-frames"
|
||||
import { ShimmerText } from "./shimmer-text"
|
||||
|
||||
export { SPINNER_FRAMES } from "./spinner-frames"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.text.subdued
|
||||
const [frame, setFrame] = createSignal(0)
|
||||
createEffect(() => {
|
||||
if (!(config.animations ?? true) || !props.shimmer) return
|
||||
const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={props.shimmer}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(shimmer) => (
|
||||
<ShimmerText fg={color()} shimmer={shimmer()}>
|
||||
{SPINNER_FRAMES[frame()]} {props.children}
|
||||
</ShimmerText>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
padding-inline-end: 6px;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
@@ -552,6 +552,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[data-value="open-file"],
|
||||
&[data-value^="file://"]
|
||||
{
|
||||
gap: 8px;
|
||||
padding-inline-end: 4px;
|
||||
|
||||
[data-slot="tabs-trigger-close-button"] {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
box-shadow: none;
|
||||
@@ -569,19 +580,19 @@
|
||||
font-variation-settings: "slnt" 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[data-selected] {
|
||||
&[data-selected]:not([data-value="open-file"]) {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.tab-fileicon-color,
|
||||
.tab-fileicon-mono,
|
||||
[data-slot="icon-svg"] {
|
||||
.tab-fileicon-mono {
|
||||
margin-inline-start: -2px;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic !important;
|
||||
font-variation-settings: "slnt" -10 !important;
|
||||
padding-inline-end: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -129,15 +129,13 @@ agents.
|
||||
|
||||
### Updates
|
||||
|
||||
Control updates from the global config. Set `update` to `"disable"` to skip
|
||||
updates, `"notify"` to report available updates without installing them, or
|
||||
`"auto"` to automatically install compatible non-major updates.
|
||||
Major updates are reported but never installed automatically.
|
||||
Control update checks from the global config. Set `update` to `"disable"` to
|
||||
skip them or `"notify"` to show available updates before installing them.
|
||||
Project-level values are ignored.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"update": "auto",
|
||||
"update": "notify",
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -409,7 +409,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` remains `"notify"`, and `true` maps to `"auto"`.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, while `"notify"` and `true` map to `"notify"`.
|
||||
- The previous V2 value `update: "auto"` is treated as `update: "notify"`.
|
||||
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||
`agents.title.model` instead.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user