diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index de45766f2ad..dc7b1471216 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Config } from "../../config" -import { Context, Effect, FileSystem, Option, Queue } from "effect" +import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect" import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" @@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) => ), ) const updater = yield* Updater.Service + const update = yield* updater.check().pipe(Effect.forkScoped) preflight.loading() const config = yield* Config.Service const npm = yield* Npm.Service @@ -83,9 +84,12 @@ export default Runtime.handler(Commands, (input) => update: (update) => runPromise(config.update(update)), }, updater: { - monitor: (notify, signal) => + remote: requestedServer !== undefined, + subscribe: (notify, signal) => runPromise( - updater.monitor((version) => Effect.sync(() => notify(version))), + Fiber.join(update).pipe( + Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))), + ), { signal }, ), apply: (version) => runPromise(updater.apply(version)), diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 3a043c40a07..1365512705d 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -13,6 +13,7 @@ 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" @@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { 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}`) + yield* Updater.Service.pipe( + Effect.flatMap((updater) => + Updater.pollUpdates({ + check: updater.check().pipe( + Effect.flatMap((result) => { + if (!result) return Effect.void + if (result.type === "available") return server.updateAvailable(result.version) + return server.updated(result.version) + }), + ), + }), + ), + Effect.provide(Updater.layer), + Effect.forkScoped, + ) return yield* options.mode === "service" ? server.shutdown : options.mode === "stdio" diff --git a/packages/cli/src/services/updater-action.ts b/packages/cli/src/services/updater-action.ts index d447b716692..e147f340683 100644 --- a/packages/cli/src/services/updater-action.ts +++ b/packages/cli/src/services/updater-action.ts @@ -1,5 +1,5 @@ -export type Policy = "disable" | "notify" -export type Action = "none" | "notify" +export type Policy = "disable" | "notify" | "auto" +export type Action = "none" | "notify" | "auto" const maximumComponent = "9007199254740991" const versionPattern = @@ -10,7 +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" - return "notify" + return policy } export function parseReleaseVersion(input: string) { diff --git a/packages/cli/src/services/updater.test.ts b/packages/cli/src/services/updater.test.ts index 3d9bbeb5458..7a108f665d7 100644 --- a/packages/cli/src/services/updater.test.ts +++ b/packages/cli/src/services/updater.test.ts @@ -6,14 +6,14 @@ 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("notify") + expect(decodePolicy('{ "update": "auto" }')).toBe("auto") 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("notify") + expect(decodePolicy('{ "autoupdate": true }')).toBe("auto") }) test("reports every available release", () => { @@ -23,6 +23,11 @@ describe("updater", () => { expect(action("1.2.3", "1.2.3", "notify")).toBe("none") }) + test("automatically installs every available release when enabled", () => { + expect(action("1.2.3", "1.2.4", "auto")).toBe("auto") + expect(action("1.2.3", "1.2.3", "auto")).toBe("none") + }) + test("skips when updates are disabled", () => { expect(action("1.2.3", "1.2.4", "disable")).toBe("none") }) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index b769cb0ca6d..7cc94c54d36 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,7 +1,7 @@ import { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" -import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect" +import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" import path from "node:path" @@ -9,28 +9,26 @@ 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 type CheckResult = { readonly type: "available" | "installed"; readonly version: string } export interface Interface { - readonly monitor: (notify: (version: string) => Effect.Effect) => Effect.Effect + readonly check: () => Effect.Effect readonly apply: (version: string) => Effect.Effect readonly method: () => Effect.Effect readonly latest: () => Effect.Effect readonly upgrade: (method: Method, version: string) => Effect.Effect } -export const monitorUpdates = Effect.fnUntraced(function* (input: { - readonly inspect: () => Effect.Effect - readonly notify: (version: string) => Effect.Effect +export const pollUpdates = Effect.fnUntraced(function* (input: { + readonly check: Effect.Effect 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)) + return yield* input.check.pipe( + Effect.repeat(Schedule.spaced(interval)), + Effect.delay(input.initialDelay ?? "1 minute"), + ) }) export class Service extends Context.Service()("@opencode/cli/Updater") {} @@ -43,20 +41,20 @@ 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") return value - if (value === "auto") return "notify" + if (value === "disable" || value === "notify" || value === "auto") return value return } if (!("autoupdate" in input)) return if (input.autoupdate === false) return "disable" if (input.autoupdate === "notify") return "notify" - if (input.autoupdate === true) return "notify" + if (input.autoupdate === true) return "auto" } const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const global = yield* Global.Service const appProcess = yield* AppProcess.Service + const installedVersion = yield* Ref.make(OPENCODE_VERSION) const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") const installedPackage = yield* Effect.gen(function* () { const executable = yield* fs.realPath(process.execPath) @@ -75,7 +73,7 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => undefined), ), ) - return values.findLast((value) => value !== undefined) ?? "notify" + return values.findLast((value) => value !== undefined) ?? "auto" }) const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") { @@ -200,18 +198,19 @@ const make = Effect.gen(function* () { return undefined } + const current = yield* Ref.get(installedVersion) const version = yield* latest() yield* Effect.logInfo("update check", { - current: OPENCODE_VERSION, + current, latest: version, }) - const next = action(OPENCODE_VERSION, version, policy) + const next = action(current, version, policy) if (next === "none") { yield* Effect.logInfo("update check done", { action: "up-to-date" }) return undefined } - yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version }) - return version + yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next }) + return { policy, version } }) const install = Effect.fnUntraced(function* (version: string) { @@ -220,8 +219,10 @@ const make = Effect.gen(function* () { yield* Effect.logWarning("update skipped: installation method not found") return false } + const current = yield* Ref.get(installedVersion) yield* upgrade(detected, version) - yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected }) + yield* Ref.set(installedVersion, version) + yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected }) return true }) @@ -229,9 +230,18 @@ const make = Effect.gen(function* () { if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found")) }) - const monitor = (notify: (version: string) => Effect.Effect) => monitorUpdates({ inspect, notify }) + const check = Effect.fn("cli.updater.check")( + function* () { + const result = yield* inspect() + if (!result) return undefined + if (result.policy === "notify") return { type: "available" as const, version: result.version } + if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found")) + return { type: "installed" as const, version: result.version } + }, + Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))), + ) - return Service.of({ monitor, apply, method, latest, upgrade }) + return Service.of({ check, apply, method, latest, upgrade }) }) export const layer = Layer.effect(Service, make) diff --git a/packages/cli/test/fixture/upgrade.ts b/packages/cli/test/fixture/upgrade.ts index 5ed8b4bc479..9fdd6e62e61 100644 --- a/packages/cli/test/fixture/upgrade.ts +++ b/packages/cli/test/fixture/upgrade.ts @@ -12,7 +12,7 @@ await Effect.runPromise( process.argv.slice(2), ).pipe( Effect.provideService(Updater.Service, { - monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"), + check: () => Effect.die("Manual upgrades must not check for automatic updates"), apply: () => Effect.die("Manual upgrades must not apply TUI updates"), method: () => Effect.sync(() => { diff --git a/packages/cli/test/updater-monitor.test.ts b/packages/cli/test/updater-monitor.test.ts deleted file mode 100644 index 92ec7372aa0..00000000000 --- a/packages/cli/test/updater-monitor.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect } from "bun:test" -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.effect("checks after 90 seconds and every 10 minutes after that", () => - Effect.gen(function* () { - const updates = yield* Queue.unbounded() - yield* Updater.monitorUpdates({ - inspect: () => Effect.succeed("2.0.0"), - notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid), - }).pipe(Effect.forkScoped) - - 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") - }), -) - -it.effect("does not notify when no update is available", () => - Effect.gen(function* () { - const updates = yield* Queue.unbounded() - yield* Updater.monitorUpdates({ - inspect: () => Effect.succeed(undefined), - notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid), - }).pipe(Effect.forkScoped) - - yield* Effect.yieldNow - expect(yield* Queue.size(updates)).toBe(0) - }), -) diff --git a/packages/cli/test/updater-poll.test.ts b/packages/cli/test/updater-poll.test.ts new file mode 100644 index 00000000000..640e32b0474 --- /dev/null +++ b/packages/cli/test/updater-poll.test.ts @@ -0,0 +1,24 @@ +import { expect } from "bun:test" +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.effect("polls after 1 minute and every 10 minutes after that", () => + Effect.gen(function* () { + const checks = yield* Queue.unbounded() + yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped) + + yield* Effect.yieldNow + expect(yield* Queue.size(checks)).toBe(0) + yield* TestClock.adjust("59 seconds") + expect(yield* Queue.size(checks)).toBe(0) + yield* TestClock.adjust("1 second") + yield* Queue.take(checks) + yield* Effect.yieldNow + yield* TestClock.adjust("10 minutes") + yield* Queue.take(checks) + }), +) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3638435548a..1717899493b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1892,7 +1892,7 @@ export type ConfigEntry = shell?: string model?: string | { providerID: string; model: string; variant?: string } default_agent?: string - update?: "disable" | "notify" + update?: "disable" | "notify" | "auto" share?: "manual" | "auto" | "disabled" enterprise?: { url?: string } username?: string diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index 7777eaa8b51..a53883cbbd1 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -74,9 +74,7 @@ export function normalize(input: unknown): Result { ? 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) + ? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics) : undefined const legacyShare = own(input, "autoshare") ? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 4ddfbe10c03..271689ce412 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) { update: info.autoupdate === false ? "disable" - : info.autoupdate === "notify" || info.autoupdate === true + : info.autoupdate === "notify" ? "notify" - : undefined, + : info.autoupdate === true + ? "auto" + : undefined, share: info.share ?? (info.autoshare ? "auto" : undefined), enterprise: info.enterprise, username: info.username, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index d7f5270b70c..36c777aaf81 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -666,14 +666,14 @@ 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("notify") + expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto") expect(ConfigMigrateV1.migrate({}).update).toBeUndefined() }) - test("normalizes the previous native auto update policy", () => { + test("normalizes the native auto update policy", () => { expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({ type: "normalized", - encoded: { update: "notify" }, + encoded: { update: "auto" }, diagnostics: [], }) }) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 54fa594848c..21cb4954ccd 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -13904,7 +13904,7 @@ }, "update": { "type": "string", - "enum": ["disable", "notify"] + "enum": ["disable", "notify", "auto"] }, "share": { "type": "string", @@ -18221,6 +18221,12 @@ "type": "string", "enum": ["auto", "manual"] }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_5" + }, "summary": { "type": "string" }, @@ -18448,6 +18454,9 @@ "Session.Message.ProviderState_4": { "type": "object" }, + "Session.Message.ProviderState_5": { + "type": "object" + }, "Session.Message.Shell": { "type": "object", "properties": { diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index 33c0a4c6a81..7ffa170bb7d 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -34,8 +34,8 @@ export class Info extends Schema.Class("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"]).pipe(optional).annotate({ - description: "Disable updates or notify when one is available", + update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({ + description: "Disable updates, notify when one is available, or install updates automatically", }), share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({ description: "Control whether sessions may be shared manually, automatically, or not at all", diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 99f3b37f1b4..87157304913 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,7 +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 { InstallationEvent } from "@opencode-ai/schema/installation-event" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty" import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect" @@ -114,7 +116,14 @@ export const start = Effect.fn("ServerProcess.start")(function* ( ) yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready - return { address: bound.http.address, shutdown: shutdown.await } + const bus = Context.get(context, Bus.Service) + return { + address: bound.http.address, + shutdown: shutdown.await, + updateAvailable: (version: string) => + bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid), + updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid), + } }).pipe( Effect.catchCause((cause) => { if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 234bd255db8..c2ef6acb5c4 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -101,7 +101,13 @@ it.live("allows browser preflight requests without credentials", () => expect(event.headers.get("content-encoding")).toBeNull() const body = event.body if (!body) return yield* Effect.die(new Error("Event response has no body")) - yield* Effect.promise(() => body.cancel()) + const reader = body.getReader() + yield* Effect.promise(() => readUntil(reader, "server.connected")) + yield* server.updateAvailable("2.0.0") + yield* Effect.promise(() => readUntil(reader, "installation.update-available")) + yield* server.updated("2.0.0") + yield* Effect.promise(() => readUntil(reader, "installation.updated")) + yield* Effect.promise(() => reader.cancel()) const missing = yield* Effect.promise(() => fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { @@ -126,3 +132,11 @@ it.live("allows browser preflight requests without credentials", () => ) }), ) + +async function readUntil(reader: ReadableStreamDefaultReader, 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 + } +} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 39c7e11e918..10dd55664f6 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -64,7 +64,6 @@ import { DialogStatus } from "./component/dialog-status" import { DialogConfig } from "./component/dialog-config" import { DialogDebug } from "./component/dialog-debug" import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair" -import { DialogUpdate } from "./component/dialog-update" import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" @@ -88,6 +87,7 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" import { newSessionLocation } from "./config/new-session-location" +import { UpdateNotificationProvider, type UpdateSource } from "./context/update-notification" import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context" import { localPluginDirectories } from "./plugin/discovery" import { PluginRoute, Slot } from "./plugin/render" @@ -185,10 +185,7 @@ export type TuiInput = { } args: Args config: Config.Interface - updater?: { - monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise - apply: (version: string) => Promise - } + updater?: UpdateSource packages: PackageSource environment?: Readonly> terminalHandoff?: () => Promise< @@ -397,22 +394,25 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - + + + + @@ -462,7 +462,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) { +function App(props: { pair?: DialogPairCredentials }) { const log = useLog({ component: "app" }) const app = useTuiApp() const startup = useTuiStartup() @@ -501,40 +501,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", { initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH }, }) - const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>( - "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( - () => ( - 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, diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx deleted file mode 100644 index 96f534250b8..00000000000 --- a/packages/tui/src/component/dialog-update.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { TextAttributes } from "@opentui/core" -import { createSignal, For, Match, Show, Switch } from "solid-js" -import { Keymap } from "../context/keymap" -import { useTheme } from "../context/theme" -import { errorMessage } from "../util/error" -import { useDialog } from "../ui/dialog" -import { Spinner } from "./spinner" - -type State = - | { type: "ready"; active: "update" | "skip" } - | { type: "installing" } - | { type: "restarting" } - | { type: "failed"; message: string } - -export function DialogUpdate(props: { - dialogKey: string - version: string - install: () => Promise - restart?: () => Promise -}) { - const dialog = useDialog() - const theme = useTheme("elevated") - const [state, setState] = createSignal({ type: "ready", active: "update" }) - const close = () => { - if (dialog.key === props.dialogKey) dialog.clear() - } - - const install = async () => { - setState({ type: "installing" }) - await props.install() - if (props.restart) { - setState({ type: "restarting" }) - await props.restart() - } - close() - } - - const beginInstall = () => { - if (state().type !== "ready") return - void install().catch((error) => setState({ type: "failed", message: errorMessage(error) })) - } - - const run = () => { - const current = state() - if (current.type !== "ready") return - if (current.active === "skip") return close() - beginInstall() - } - - const toggle = () => - setState((current) => - current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current, - ) - - const selected = (action: "update" | "skip") => { - const current = state() - return current.type === "ready" && current.active === action - } - - const failure = () => { - const current = state() - return current.type === "failed" ? current.message : "" - } - - Keymap.createLayer(() => ({ - mode: "modal", - commands: [ - { - bind: "return", - title: "Confirm update action", - group: "Dialog", - run: () => (state().type === "failed" ? close() : run()), - }, - { - bind: "left", - title: "Previous update action", - group: "Dialog", - run: toggle, - }, - { - bind: "right", - title: "Next update action", - group: "Dialog", - run: toggle, - }, - ], - })) - - return ( - - - - Update available - - - esc - - - - - - - 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."} - - - - Installing OpenCode {props.version}… - - - Restarting the background service… - - - {failure()} - - - - - - - close - - - - } - > - - - {(action) => ( - { - if (action === "skip") return close() - beginInstall() - }} - > - - {action === "update" ? "Update" : "Skip"} - - - )} - - - - - ) -} diff --git a/packages/tui/src/component/fade-in-text.tsx b/packages/tui/src/component/fade-in-text.tsx new file mode 100644 index 00000000000..ebe5ef683d9 --- /dev/null +++ b/packages/tui/src/component/fade-in-text.tsx @@ -0,0 +1,151 @@ +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 { useConfig } from "../config" +import { coast, smootherstep } from "./tab-pulse" + +type FadeInTextOptions = TextOptions & { + backdrop?: RGBA + enabled?: boolean + sweepOffset?: number + sweepWidth?: number +} + +const DURATION = 200 +const FEATHER = 8 +const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0) +const CONTINUATION = 0xc0000000 | 0 +const clamp = (value: number) => Math.max(0, Math.min(1, value)) + +class FadeInTextRenderable extends TextRenderable { + private _backdrop = RGBA.defaultBackground() + private _enabled = true + private _sweepOffset = 0 + private _sweepWidth: number | undefined + private elapsed = 0 + private scratch: OptimizedBuffer | undefined + private mask = new Float32Array(0) + private matrix = new Float32Array(16) + + constructor(ctx: RenderContext, options: FadeInTextOptions) { + super(ctx, options) + this.matrix[15] = 1 + this.updateBackdrop() + if (options.backdrop) this.backdrop = options.backdrop + if (options.enabled === false) this.enabled = false + this.live = this._enabled + } + + set backdrop(value: RGBA) { + if (value.equals(this._backdrop)) return + this._backdrop = value + this.updateBackdrop() + this.requestRender() + } + + set enabled(value: boolean) { + if (value === this._enabled) return + this._enabled = value + this.live = value && this.elapsed < DURATION + this.requestRender() + } + + set sweepOffset(value: number | undefined) { + this._sweepOffset = value ?? 0 + this.requestRender() + } + + set sweepWidth(value: number | undefined) { + this._sweepWidth = value + this.requestRender() + } + + private updateBackdrop() { + this.matrix[3] = this._backdrop.r + this.matrix[7] = this._backdrop.g + this.matrix[11] = this._backdrop.b + } + + override render(buffer: OptimizedBuffer, deltaTime: number) { + if (!this._enabled || this.elapsed >= DURATION) return super.render(buffer, deltaTime) + if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return + this.elapsed = Math.min(DURATION, this.elapsed + deltaTime) + 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 progress = this.elapsed / DURATION + const front = -FEATHER + coast(progress) * ((this._sweepWidth ?? end) + FEATHER * 2) + if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3) + let strength = 1 + for (let cell = 0; cell < characters.length; cell++) { + const column = cell % this.width + if ((characters[cell] & CONTINUATION) !== CONTINUATION) + strength = 1 - smootherstep(clamp((front - (this._sweepOffset + column)) / FEATHER)) + 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) + if (this.elapsed >= DURATION) this.live = false + } + + override destroy() { + this.scratch?.destroy() + this.scratch = undefined + super.destroy() + } +} + +extend({ fade_in_text: FadeInTextRenderable }) + +declare module "@opentui/solid" { + interface OpenTUIComponents { + fade_in_text: typeof FadeInTextRenderable + } +} + +type Props = Omit & { + animate?: boolean + backdrop?: RGBA + sweepOffset?: number + sweepWidth?: number +} + +export function FadeInText(props: Props) { + const config = useConfig().data + const [local, text] = splitProps(props, ["animate", "backdrop", "sweepOffset", "sweepWidth"]) + return ( + + ) +} diff --git a/packages/tui/src/component/shimmer-text.tsx b/packages/tui/src/component/shimmer-text.tsx deleted file mode 100644 index a1e06eade50..00000000000 --- a/packages/tui/src/component/shimmer-text.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 & { shimmer: RGBA } - -export function ShimmerText(props: Props) { - const [local, text] = splitProps(props, ["shimmer"]) - return -} diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index 0bed53f45ac..1cca2f4bcff 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -1,48 +1,30 @@ -import { createEffect, createSignal, onCleanup, Show } from "solid-js" +import { 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; shimmer?: RGBA }) { +export function Spinner(props: { children?: JSX.Element; color?: 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 ( {props.children ? <>⋯ {props.children} : "⋯"}} > - - - - {props.children} - - - } - > - {(shimmer) => ( - - {SPINNER_FRAMES[frame()]} {props.children} - - )} - + + + + {props.children} + + ) } diff --git a/packages/tui/src/context/update-notification.tsx b/packages/tui/src/context/update-notification.tsx new file mode 100644 index 00000000000..cf2af29da2e --- /dev/null +++ b/packages/tui/src/context/update-notification.tsx @@ -0,0 +1,127 @@ +import { createSignal, onCleanup, onMount } from "solid-js" +import { createSimpleContext } from "./helper" +import { useLog } from "./log" +import { useStorage } from "./storage" +import { useEvent } from "./event" +import { errorMessage } from "../util/error" +import { useExit } from "./exit" + +type ClientNotice = { readonly type: "available" | "installed"; readonly version: string } +type Notice = ClientNotice & ({ readonly source: "client" } | { readonly source: "server"; readonly remote: boolean }) +export type UpdateNotificationState = + | Notice + | { readonly source: "client"; readonly type: "installing"; readonly version: string } + | { readonly source: "client"; readonly type: "install-success"; readonly version: string } + | { readonly source: "client"; readonly type: "failed"; readonly version: string; readonly message: string } + +export type UpdateSource = { + readonly remote: boolean + readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise + readonly apply: (version: string) => Promise +} + +export const { use: useUpdateNotification, provider: UpdateNotificationProvider } = createSimpleContext({ + name: "UpdateNotification", + init: (props: { updater?: UpdateSource }) => { + const event = useEvent() + const exit = useExit() + const log = useLog({ component: "update-notification" }) + const [state, setState] = createSignal() + const [notifications, markNotification] = useStorage().store<{ versions: string[] }>("update-notifications", { + initial: { versions: [] }, + }) + + const notify = (notice: Notice) => { + if ( + !props.updater || + notifications.versions.includes(`${notice.source}:${notice.version}`) || + (notice.source === "client" && notifications.versions.includes(notice.version)) + ) + return + setState((current) => { + if (notice.source === "server" && current?.source === "client") return current + return notice + }) + } + + const seen = (source: Notice["source"], version: string) => + markNotification((draft) => { + draft.versions = [...draft.versions, `${source}:${version}`].slice(-100) + }).catch((error) => log.error("failed to persist update notification", { error })) + + const skip = () => { + const current = state() + if (!current || current.type !== "available" || (current.source === "server" && current.remote)) return + setState(undefined) + void seen(current.source, current.version) + } + + const close = () => { + const current = state() + if (!current || current.source !== "server") return + setState(undefined) + void seen(current.source, current.version) + } + + const install = async () => { + const updater = props.updater + const current = state() + if (!updater || !current || current.type !== "available" || (current.source === "server" && current.remote)) + return + setState({ source: "client", type: "installing", version: current.version }) + void seen(current.source, current.version) + await updater.apply(current.version).then( + () => setState({ source: "client", type: "install-success", version: current.version }), + (error) => + setState({ source: "client", type: "failed", version: current.version, message: errorMessage(error) }), + ) + } + + const restart = () => { + const current = state() + if (!current || (current.type !== "installed" && current.type !== "install-success")) return + exit() + } + + const later = () => { + const current = state() + if (!current || (current.type !== "installed" && current.type !== "install-success")) return + setState(undefined) + } + + onMount(() => { + const updater = props.updater + if (!updater) return + const controller = new AbortController() + onCleanup(() => controller.abort()) + void updater + .subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal) + .catch((error) => { + if (!controller.signal.aborted) log.error("update check failed", { error }) + }) + }) + + onCleanup( + event.on("installation.update-available", (event) => + notify({ + source: "server", + remote: props.updater?.remote ?? false, + type: "available", + version: event.data.version, + }), + ), + ) + onCleanup( + event.on("installation.updated", (event) => + notify({ + source: "server", + remote: props.updater?.remote ?? false, + type: "installed", + version: event.data.version, + }), + ), + ) + + return { state, skip, close, install, restart, later } + }, +}) diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 0180d9a5ffa..3601a0bd7c3 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "../component/prompt" -import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js" +import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" import { Logo } from "../component/logo" import { useArgs } from "../context/args" import { useRouteData } from "../context/route" @@ -11,6 +11,12 @@ import { useLocation } from "../context/location" import { FormPrompt } from "./session/form" import { Slot } from "../plugin/render" import { useTerminalDimensions } from "@opentui/solid" +import { TextAttributes, type RGBA } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useUpdateNotification } from "../context/update-notification" +import { Spinner } from "../component/spinner" +import { FadeInText } from "../component/fade-in-text" +import { stringWidth } from "../util/string-width" let once = false const placeholder = { @@ -81,13 +87,16 @@ export function Home() { paddingRight={dimensions().width < 44 ? 1 : 2} > - + - + 0} /> + + + @@ -109,3 +118,174 @@ export function Home() { ) } + +function UpdateNotification() { + const update = useUpdateNotification() + const theme = useTheme() + const action = theme.text.action.primary.selected + const [hovered, setHovered] = createSignal<"primary" | "skip" | "later" | "close">() + createEffect(() => { + update.state() + setHovered(undefined) + }) + + return ( + + {(state) => ( + + + + + setHovered("primary")} + onMouseOut={() => setHovered(undefined)} + onMouseUp={() => void update.install()} + > + + + + setHovered("skip")} + onMouseOut={() => setHovered(undefined)} + onMouseUp={update.skip} + > + Skip this version + + + + + + + + setHovered("close")} + onMouseOut={() => setHovered(undefined)} + onMouseUp={update.close} + > + Close + + + + + Installing update… + + + + setHovered("primary")} + onMouseOut={() => setHovered(undefined)} + onMouseUp={update.restart} + > + + + + setHovered("later")} + onMouseOut={() => setHovered(undefined)} + onMouseUp={update.later} + > + Restart later + + + + + + {state.type === "failed" ? state.message : ""} + + + + )} + + ) +} + +function UpdateMessage(props: { title: string; description: string; backdrop: RGBA; animate?: boolean }) { + const theme = useTheme() + const lines = props.description.split("\n") + const width = Math.max(stringWidth(props.title), ...lines.map((line) => stringWidth(line))) + const padding = " ".repeat(Math.floor((width - stringWidth(props.title)) / 2)) + const description = lines.map((line) => " ".repeat(Math.floor((width - stringWidth(line)) / 2)) + line).join("\n") + return ( + + + {padding} + {props.title} + + {"\n"} + {description} + + ) +} diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 54fa594848c..21cb4954ccd 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -13904,7 +13904,7 @@ }, "update": { "type": "string", - "enum": ["disable", "notify"] + "enum": ["disable", "notify", "auto"] }, "share": { "type": "string", @@ -18221,6 +18221,12 @@ "type": "string", "enum": ["auto", "manual"] }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_5" + }, "summary": { "type": "string" }, @@ -18448,6 +18454,9 @@ "Session.Message.ProviderState_4": { "type": "object" }, + "Session.Message.ProviderState_5": { + "type": "object" + }, "Session.Message.Shell": { "type": "object", "properties": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 54fa594848c..21cb4954ccd 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -13904,7 +13904,7 @@ }, "update": { "type": "string", - "enum": ["disable", "notify"] + "enum": ["disable", "notify", "auto"] }, "share": { "type": "string", @@ -18221,6 +18221,12 @@ "type": "string", "enum": ["auto", "manual"] }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_5" + }, "summary": { "type": "string" }, @@ -18448,6 +18454,9 @@ "Session.Message.ProviderState_4": { "type": "object" }, + "Session.Message.ProviderState_5": { + "type": "object" + }, "Session.Message.Shell": { "type": "object", "properties": { diff --git a/packages/www/src/docs/content/config.mdx b/packages/www/src/docs/content/config.mdx index f0d93fd4ced..e2f138084bd 100644 --- a/packages/www/src/docs/content/config.mdx +++ b/packages/www/src/docs/content/config.mdx @@ -130,7 +130,10 @@ agents. ### Updates Control update checks from the global config. Set `update` to `"disable"` to -skip them or `"notify"` to show available updates before installing them. +skip them, `"notify"` to show available updates before installing them, or +`"auto"` to install updates automatically. When omitted, `update` defaults to `"auto"`. + +Automatic installation does not restart a running server. Restart it manually to activate the installed update. Project-level values are ignored. ```jsonc diff --git a/packages/www/src/docs/content/migrate-v1.mdx b/packages/www/src/docs/content/migrate-v1.mdx index ac939218ea9..9d14cbd8490 100644 --- a/packages/www/src/docs/content/migrate-v1.mdx +++ b/packages/www/src/docs/content/migrate-v1.mdx @@ -409,8 +409,7 @@ 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"`, while `"notify"` and `true` map to `"notify"`. -- The previous V2 value `update: "auto"` is treated as `update: "notify"`. +- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` maps to `"notify"`, and `true` maps to `"auto"`. - `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use `agents.title.model` instead.