diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 7ebb4b69b023..651de36fae5a 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -63,7 +63,7 @@ export const Info = Schema.Struct({ }), autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({ description: - "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", + "Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same", }), disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Disable providers that are loaded automatically", diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index d5fa46bfb115..f84ffc428bad 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -10,6 +10,8 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "@opencode-ai/tui/context/sdk" +import type { UpdateNotice, UpdateSource } from "@opencode-ai/tui" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { writeHeapSnapshot } from "v8" import { ServerAuth } from "@/server/auth" import { validateSession } from "../tui/validate-session" @@ -49,6 +51,41 @@ function createEventSource(client: RpcClient): EventSource { } } +// Mirrors the V2 CLI updater: the TUI process checks every 10 minutes and installs on this machine, +// but only when the user picks an option in the /update dialog. +function createUpdater(latest: () => Promise): UpdateSource { + const listeners = new Set<(notice: UpdateNotice) => void>() + let current: UpdateNotice | undefined + const check = () => + latest() + .then((notice) => { + if (!notice || JSON.stringify(notice) === JSON.stringify(current)) return + current = notice + listeners.forEach((notify) => notify(notice)) + }) + .catch(() => {}) + setTimeout(check, 1000).unref?.() + setInterval(check, 10 * 60 * 1000).unref?.() + return { + subscribe: (notify) => { + if (current) notify(current) + listeners.add(notify) + return () => listeners.delete(notify) + }, + check: async () => { + const { Installation } = await import("@/installation") + return { current: InstallationVersion, latest: await Installation.latest(await Installation.method()) } + }, + apply: async (target) => { + const { Installation } = await import("@/installation") + const method = await Installation.method() + if (method === "unknown") throw new Error("Installation method not found") + if (target.type === "major") return Installation.upgradeMajor(method) + return Installation.upgrade(method, target.version) + }, + } +} + async function target() { if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH const dist = new URL("./cli/tui/worker.js", import.meta.url) @@ -262,9 +299,7 @@ export const TuiThreadCommand = cmd({ return } - setTimeout(() => { - client.call("checkUpgrade", { directory: cwd }).catch(() => {}) - }, 1000).unref?.() + const updater = createUpdater(async () => await client.call("checkUpgrade", { directory: cwd })) try { const { Effect } = await import("effect") @@ -284,6 +319,7 @@ export const TuiThreadCommand = cmd({ fetch: transport.fetch, headers: transport.headers, events: transport.events, + updater, args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446b3..e6aa5163c2aa 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -58,7 +58,7 @@ export const rpc = { }, async checkUpgrade(input: { directory: string }) { await InstanceRuntime.load({ directory: input.directory }) - await upgrade().catch(() => {}) + return upgrade().catch(() => undefined) }, async reload() { await AppRuntime.runPromise( diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 62b230a63364..de009f719151 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -3,51 +3,35 @@ import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@opencode-ai/core/flag/flag" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { GlobalBus } from "@/bus/global" +// Checking never installs anything. Updates only happen when the user runs `/update` or `opencode upgrade`. +export function shouldNotify(input: { + autoupdate: boolean | "notify" | undefined + disabled: boolean + always: boolean + current: string + latest: string +}) { + if (input.autoupdate === false || input.disabled) return false + if (input.always) return true + return input.current !== input.latest +} + +// Returns undefined when checks are disabled or fail, so the TUI only suggests OpenCode 2 after a successful check. export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return - const method = await Installation.method() - const latest = await Installation.latest(method).catch(() => {}) + const latest = await Installation.latest(await Installation.method()).catch(() => {}) if (!latest) return - - if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.UpdateAvailable.type, - properties: { version: latest }, - }, - }) - return - } - - if (InstallationVersion === latest) return - - const kind = Installation.getReleaseType(InstallationVersion, latest) - - if (config.autoupdate === "notify" || kind !== "patch") { - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.UpdateAvailable.type, - properties: { version: latest }, - }, + if ( + shouldNotify({ + autoupdate: config.autoupdate, + disabled: Flag.OPENCODE_DISABLE_AUTOUPDATE, + always: Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE, + current: InstallationVersion, + latest, }) - return - } - - if (method === "unknown") return - await Installation.upgrade(method, latest) - .then(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: latest }, - }, - }), - ) - .catch(() => {}) + ) + return { type: "available" as const, version: latest } + return { type: "major" as const } } diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad233807..d22ded5af10f 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -185,7 +185,8 @@ Options: --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] -m, --method installation method to use - [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]" + [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"] + --major upgrade to OpenCode 2 and remove OpenCode 1 [boolean]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode uninstall --help 1`] = ` diff --git a/packages/opencode/test/cli/upgrade.test.ts b/packages/opencode/test/cli/upgrade.test.ts new file mode 100644 index 000000000000..3159e2b225d7 --- /dev/null +++ b/packages/opencode/test/cli/upgrade.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { shouldNotify } from "../../src/cli/upgrade" + +const base = { autoupdate: undefined, disabled: false, always: false, current: "1.4.0", latest: "1.4.1" } + +describe("upgrade check", () => { + test("notifies about every newer release, including patches", () => { + expect(shouldNotify(base)).toBe(true) + expect(shouldNotify({ ...base, latest: "2.0.0" })).toBe(true) + }) + + test("treats true and notify the same", () => { + expect(shouldNotify({ ...base, autoupdate: true })).toBe(true) + expect(shouldNotify({ ...base, autoupdate: "notify" })).toBe(true) + }) + + test("does not notify when already on the latest version", () => { + expect(shouldNotify({ ...base, latest: "1.4.0" })).toBe(false) + }) + + test("never notifies when update checks are disabled", () => { + expect(shouldNotify({ ...base, autoupdate: false })).toBe(false) + expect(shouldNotify({ ...base, disabled: true, always: true })).toBe(false) + }) + + test("always-notify flag forces a notice for the current version", () => { + expect(shouldNotify({ ...base, latest: "1.4.0", always: true })).toBe(true) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f06c20cc413e..7c04dac47346 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1927,7 +1927,7 @@ export type Config = { share?: "manual" | "auto" | "disabled" autoshare?: boolean /** - * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications + * Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same */ autoupdate?: boolean | "notify" disabled_providers?: Array diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e66d14050558..32085cf74d6d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21263,7 +21263,7 @@ "enum": ["notify"] } ], - "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications" + "description": "Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same" }, "disabled_providers": { "type": "array", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c331ed609a50..1b21aafade52 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -56,11 +56,10 @@ import { Session } from "./routes/session" import { PromptHistoryProvider } from "./component/prompt/history" import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" -import { DialogAlert } from "./ui/dialog-alert" -import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" import { isDefaultTitle } from "./util/session" import { KVProvider, useKV } from "./context/kv" +import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification" import * as Model from "./util/model" import { ArgsProvider, useArgs, type Args } from "./context/args" import { openUrl } from "@opencode-ai/core/open" @@ -149,6 +148,7 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + updater?: UpdateSource } function errorMessage(error: unknown) { @@ -166,23 +166,6 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } -function isVersionGreater(left: string, right: string) { - const parse = (value: string) => { - const [core, prerelease] = value.replace(/^v/, "").split("-", 2) - return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease } - } - const a = parse(left) - const b = parse(right) - for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) { - const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0) - if (difference) return difference > 0 - } - if (a.prerelease === b.prerelease) return false - if (!a.prerelease) return true - if (!b.prerelease) return false - return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0 -} - export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const global = yield* Global.Service const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } @@ -315,10 +298,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + @@ -386,6 +371,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const pluginRuntime = usePluginRuntime() const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const clipboard = useClipboard() + const update = useUpdateNotification() const api = createTuiApi( createTuiApiAdapters({ @@ -771,6 +757,17 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + ...(update.open + ? [ + { + name: "opencode.update", + title: "Update OpenCode", + slashName: "update", + run: () => update.open?.(), + category: "System", + }, + ] + : []), { name: "opencode.debug", title: "View debug info", @@ -1030,54 +1027,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) - event.on("installation.update-available", async (evt) => { - console.log("installation.update-available", evt) - const version = evt.properties.version - - const skipped = kv.get("skipped_version") - if (skipped && !isVersionGreater(version, skipped)) return - - const choice = await DialogConfirm.show( - dialog, - `Update Available`, - `A new release v${version} is available. Would you like to update now?`, - "skip", - ) - - if (choice === false) { - kv.set("skipped_version", version) - return - } - - if (choice !== true) return - - toast.show({ - variant: "info", - message: `Updating to v${version}…`, - duration: 30000, - }) - - const result = await sdk.client.global.upgrade({ target: version }) - - if (result.error || !result.data?.success) { - toast.show({ - variant: "error", - title: "Update Failed", - message: "Update failed", - duration: 10000, - }) - return - } - - await DialogAlert.show( - dialog, - "Update Complete", - `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, - ) - - void exit() - }) - const plugin = createMemo(() => { if (!ready()) return if (route.data.type !== "plugin") return diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx new file mode 100644 index 000000000000..5461beafb45a --- /dev/null +++ b/packages/tui/src/component/dialog-update.tsx @@ -0,0 +1,186 @@ +import { TextAttributes } from "@opentui/core" +import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" +import { useTheme } from "../context/theme" +import type { UpdateState, UpdateTarget } from "../context/update-notification" +import { useDialog } from "../ui/dialog" +import { useBindings } from "../keymap" +import { errorMessage } from "../util/error" +import { Spinner } from "./spinner" +import { openUrl } from "@opencode-ai/core/open" + +export const V2_ANNOUNCEMENT_URL = "https://anoma.ly/notes/opencode-2-0" + +type Version = { current: string; latest?: string } + +// A newer OpenCode 1 release uses the normal update flow; OpenCode 2.0 is only offered when OpenCode 1 is current. +export function updateTarget(version: Version): UpdateTarget { + if (version.latest && version.latest !== version.current) return { type: "latest", version: version.latest } + return { type: "major" } +} + +export function DialogUpdate(props: { + check: () => Promise + state: () => UpdateState | undefined + install: (target: UpdateTarget) => Promise + skip: (version: string) => void + dismiss: () => void + restart: () => void +}) { + const dialog = useDialog() + const { theme } = useTheme() + const [error, setError] = createSignal() + const [active, setActive] = createSignal(0) + const [check] = createResource(() => + props.check().catch((error: unknown) => { + setError(errorMessage(error)) + return undefined + }), + ) + + const target = () => { + const version = check() + if (!version?.latest) return undefined + return updateTarget(version) + } + const state = createMemo(() => { + const current = props.state() + if (current?.type === "installing" || current?.type === "installed") return current + if (check.loading) return { type: "checking" as const } + if (current?.type === "failed") return current + const next = target() + if (!next) return { type: "check-failed" as const, message: error() ?? "Couldn't check for updates." } + if (next.type === "major") return { type: "major" as const } + return { type: "available" as const, version: next.version } + }) + const buttons = createMemo(() => { + const current = state() + if (current.type === "checking" || current.type === "installing") return [] + const next = target() + const close = { + label: next?.type === "major" ? "Dismiss" : "Skip", + run: () => { + if (current.type !== "installed" && next?.type === "major") props.dismiss() + if (current.type !== "installed" && next?.type === "latest") props.skip(next.version) + dialog.clear() + }, + } + if (current.type === "installed") return [close, { label: "Restart", run: props.restart }] + if (!next) return [close] + return [close, { label: next.type === "major" ? "Upgrade" : "Update", run: () => props.install(next) }] + }) + + createEffect(() => setActive(Math.max(0, buttons().length - 1))) + + const move = (offset: number) => { + const count = buttons().length + if (count) setActive((value) => (value + offset + count) % count) + } + + useBindings(() => ({ + bindings: [ + { key: "return", desc: "Confirm update action", group: "Dialog", cmd: () => void buttons()[active()]?.run() }, + { key: "left", desc: "Previous update action", group: "Dialog", cmd: () => move(-1) }, + { key: "shift+tab", desc: "Previous update action", group: "Dialog", cmd: () => move(-1) }, + { key: "right", desc: "Next update action", group: "Dialog", cmd: () => move(1) }, + { key: "tab", desc: "Next update action", group: "Dialog", cmd: () => move(1) }, + ], + })) + + return ( + + + + {state().type === "installing" + ? "Updating OpenCode" + : state().type === "available" || state().type === "major" || state().type === "failed" + ? "Update available" + : "Update"} + + dialog.clear()}> + esc + + + + + {(current) => ( + + + Checking for updates… + + + + An update is available. After installing, you'll be prompted to restart OpenCode. + + + + {(installing) => ( + + {installing.target.type === "major" + ? "Installing OpenCode 2.0…" + : `Installing OpenCode ${installing.target.version}…`} + + )} + + + {(installed) => ( + + {installed.target.type === "major" + ? "Update successful! A restart is required. Run `opencode` to start OpenCode 2.0." + : "Update successful! A restart is required."} + + )} + + + + + + OpenCode 2.0 is the next major release. + + { + openUrl(V2_ANNOUNCEMENT_URL).catch(() => {}) + }} + > + Read more about it:{" "} + + {V2_ANNOUNCEMENT_URL} + + + + + It contains some breaking changes. + + + + + {(failed) => ( + + {failed.message} + + )} + + + )} + + + 0}> + + + {(button, index) => ( + void button.run()} + > + {button.label} + + )} + + + + + ) +} diff --git a/packages/tui/src/context/update-notification.tsx b/packages/tui/src/context/update-notification.tsx new file mode 100644 index 000000000000..22f181ad7b16 --- /dev/null +++ b/packages/tui/src/context/update-notification.tsx @@ -0,0 +1,132 @@ +import { createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { createSimpleContext } from "./helper" +import { useKV } from "./kv" +import { useExit } from "./exit" +import { useDialog } from "../ui/dialog" +import { errorMessage } from "../util/error" +import { DialogUpdate } from "../component/dialog-update" + +export type UpdateTarget = { readonly type: "latest"; readonly version: string } | { readonly type: "major" } + +export type UpdateState = + | { readonly type: "installing"; readonly target: UpdateTarget } + | { readonly type: "installed"; readonly target: UpdateTarget } + | { readonly type: "failed"; readonly target: UpdateTarget; readonly message: string } + +// A newer OpenCode 1 release wins; OpenCode 2.0 is only suggested once OpenCode 1 is up to date. +export type UpdateNotice = { readonly type: "available"; readonly version: string } | { readonly type: "major" } + +// Implemented by the CLI process so checks and installs run against this machine's installation. +export type UpdateSource = { + readonly subscribe: (notify: (notice: UpdateNotice) => void) => () => void + readonly check: () => Promise<{ readonly current: string; readonly latest?: string }> + readonly apply: (target: UpdateTarget) => Promise +} + +export const DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000 + +// Skipping an OpenCode 1 version hides it until a newer one ships; dismissing the OpenCode 2.0 offer hides it for a week. +export function visibleNotice( + notice: UpdateNotice | undefined, + hidden: { readonly skippedVersion?: string; readonly dismissedUntil?: number }, + now: number, +) { + if (!notice) return undefined + if (notice.type === "major") + return hidden.dismissedUntil !== undefined && now < hidden.dismissedUntil ? undefined : notice + if (hidden.skippedVersion && !isVersionGreater(notice.version, hidden.skippedVersion)) return undefined + return notice +} + +export const MAJOR_INSTRUCTIONS = "https://opencode.ai/v2/docs" + +export const { use: useUpdateNotification, provider: UpdateNotificationProvider } = createSimpleContext({ + name: "UpdateNotification", + init: (props: { updater?: UpdateSource }) => { + const kv = useKV() + const dialog = useDialog() + const exit = useExit() + const [available, setAvailable] = createSignal() + const [state, setState] = createSignal() + const [now, setNow] = createSignal(Date.now()) + const clock = setInterval(() => setNow(Date.now()), 10 * 60 * 1000) + onCleanup(() => clearInterval(clock)) + + // Checks only announce a version. Installing always goes through /update. + onMount(() => { + if (!props.updater) return + onCleanup(props.updater.subscribe(setAvailable)) + }) + + const notification = createMemo(() => { + const current = state() + if (current?.type === "installed") return current + return visibleNotice( + available(), + { skippedVersion: kv.get("skipped_version"), dismissedUntil: kv.get("update_dismissed_until") }, + now(), + ) + }) + + const install = async (target: UpdateTarget) => { + const updater = props.updater + if (!updater || state()?.type === "installing") return + setState({ type: "installing", target }) + await updater.apply(target).then( + () => setState({ type: "installed", target }), + (error: unknown) => { + const message = errorMessage(error) + setState({ + type: "failed", + target, + message: + target.type === "major" && !message.includes(MAJOR_INSTRUCTIONS) + ? `${message}\nTo install OpenCode 2.0 manually, see ${MAJOR_INSTRUCTIONS}` + : message, + }) + }, + ) + } + + const open = () => { + const updater = props.updater + if (!updater) return + dialog.replace(() => ( + kv.set("skipped_version", version)} + dismiss={() => { + const at = Date.now() + kv.set("update_dismissed_until", at + DISMISS_DURATION) + setNow(at) + }} + restart={() => exit()} + /> + )) + } + + return { + notification, + open: props.updater ? open : undefined, + } + }, +}) + +export function isVersionGreater(left: string, right: string) { + const parse = (value: string) => { + const [core, prerelease] = value.replace(/^v/, "").split("-", 2) + return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease } + } + const a = parse(left) + const b = parse(right) + for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) { + const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0) + if (difference) return difference > 0 + } + if (a.prerelease === b.prerelease) return false + if (!a.prerelease) return true + if (!b.prerelease) return false + return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0 +} diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 722c2baf51b0..14780946b5d4 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1 +1,2 @@ export { run, type TuiInput } from "./app" +export type { UpdateNotice, UpdateSource } from "./context/update-notification" diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4145e2d87be..344cfbebdbff 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 } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Logo } from "../component/logo" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" @@ -12,6 +12,9 @@ import { useEditorContext } from "../context/editor" import { useTerminalDimensions } from "@opentui/solid" import { useTuiConfig } from "../config" import { HomeSessionDestinationProvider } from "./home/session-destination" +import { useUpdateNotification } from "../context/update-notification" +import { useTheme } from "../context/theme" +import { useExit } from "../context/exit" let once = false const placeholder = { @@ -30,6 +33,7 @@ export function Home() { const editor = useEditorContext() const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() + const [logoWidth, setLogoWidth] = createSignal(0) const promptMaxWidth = createMemo(() => { const configured = tuiConfig.prompt?.max_width if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) @@ -72,12 +76,18 @@ export function Home() { - + + } placeholders={placeholder} /> @@ -93,3 +103,47 @@ export function Home() { ) } + +function UpdateNotification(props: { width: number }) { + const update = useUpdateNotification() + const exit = useExit() + const { theme } = useTheme() + const [hovered, setHovered] = createSignal(false) + createEffect(() => { + update.notification() + setHovered(false) + }) + + return ( + + {(state) => ( + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseUp={() => { + if (state.type === "installed") return exit() + update.open?.() + }} + > + + {state.type === "installed" ? "/exit" : "/update"} + {state.type === "available" + ? ` to install v${state.version}` + : state.type === "major" + ? " to upgrade to OpenCode 2.0" + : state.target.type === "major" + ? " restart to use OpenCode 2.0" + : ` restart to use v${state.target.version}`} + + + )} + + ) +} diff --git a/packages/tui/test/component/dialog-update.test.ts b/packages/tui/test/component/dialog-update.test.ts new file mode 100644 index 000000000000..17535721bf5c --- /dev/null +++ b/packages/tui/test/component/dialog-update.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { updateTarget } from "../../src/component/dialog-update" +import { DISMISS_DURATION, isVersionGreater, visibleNotice } from "../../src/context/update-notification" + +describe("dialog update", () => { + test("uses the normal OpenCode 1 update when a newer release exists", () => { + expect(updateTarget({ current: "1.4.0", latest: "1.4.2" })).toEqual({ type: "latest", version: "1.4.2" }) + }) + + test("only offers OpenCode 2 when OpenCode 1 is up to date", () => { + expect(updateTarget({ current: "1.4.2", latest: "1.4.2" })).toEqual({ type: "major" }) + }) + + test("skipping an OpenCode 1 version hides it until a newer one ships", () => { + const now = 1_000_000 + expect(visibleNotice({ type: "available", version: "1.4.2" }, {}, now)).toEqual({ type: "available", version: "1.4.2" }) + expect(visibleNotice({ type: "available", version: "1.4.2" }, { skippedVersion: "1.4.2" }, now)).toBeUndefined() + expect(visibleNotice({ type: "available", version: "1.4.3" }, { skippedVersion: "1.4.2" }, now)).toEqual({ + type: "available", + version: "1.4.3", + }) + }) + + test("dismissing the OpenCode 2.0 offer hides it for a week", () => { + const now = 1_000_000 + const dismissedUntil = now + DISMISS_DURATION + expect(DISMISS_DURATION).toBe(7 * 24 * 60 * 60 * 1000) + expect(visibleNotice({ type: "major" }, { dismissedUntil }, now)).toBeUndefined() + expect(visibleNotice({ type: "major" }, { dismissedUntil }, dismissedUntil - 1)).toBeUndefined() + expect(visibleNotice({ type: "major" }, { dismissedUntil }, dismissedUntil)).toEqual({ type: "major" }) + }) + + test("skipping and dismissing are independent", () => { + const now = 1_000_000 + expect(visibleNotice({ type: "major" }, { skippedVersion: "1.4.2" }, now)).toEqual({ type: "major" }) + expect( + visibleNotice({ type: "available", version: "1.4.3" }, { dismissedUntil: now + DISMISS_DURATION }, now), + ).toEqual({ type: "available", version: "1.4.3" }) + }) + + test("compares versions for skipping", () => { + expect(isVersionGreater("1.4.2", "1.4.2")).toBe(false) + expect(isVersionGreater("1.4.3", "1.4.2")).toBe(true) + expect(isVersionGreater("1.5.0", "1.10.0")).toBe(false) + expect(isVersionGreater("1.4.2", "1.4.2-beta.1")).toBe(true) + }) +}) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4e4fea2b46ac..2b88b586c8b1 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -651,11 +651,18 @@ To upgrade to a specific version. opencode upgrade v0.1.48 ``` +To upgrade to OpenCode 2 and remove OpenCode 1. + +```bash +opencode upgrade --major +``` + #### Flags | Flag | Short | Description | | -------------------------------------- | ----- | ----------------------------------------------------------------- | | {"--method"} | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | +| {"--major"} | | Upgrade to OpenCode 2 and remove OpenCode 1 | --- @@ -685,7 +692,7 @@ OpenCode can be configured using environment variables. | `OPENCODE_TUI_CONFIG` | string | Path to TUI config file | | `OPENCODE_CONFIG_DIR` | string | Path to config directory | | `OPENCODE_CONFIG_CONTENT` | string | Inline json config content | -| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks | +| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable update checks and notices | | `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data | | `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates | | `OPENCODE_PERMISSION` | string | Inlined json permissions config | diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 22c0640f662f..c7263dabbc99 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -645,7 +645,9 @@ Note that disabling snapshots means changes made by the agent cannot be rolled b ### Autoupdate -OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option. +OpenCode checks for new versions in the background and shows a notice when one is available. Updates are never installed automatically. Run [`/update`](/docs/tui#update) in the TUI or [`opencode upgrade`](/docs/cli#upgrade) to install one. + +You can turn off update checks and notices with the `autoupdate` option. ```json title="opencode.json" { @@ -654,8 +656,7 @@ OpenCode will automatically download any new updates when it starts up. You can } ``` -If you don't want updates but want to be notified when a new version is available, set `autoupdate` to `"notify"`. -Notice that this only works if it was not installed using a package manager such as Homebrew. +Setting `autoupdate` to `true` or `"notify"` keeps the default behavior: you're notified, but nothing is installed until you run `/update`. --- diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 856ef392c748..0351c46cde10 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -283,6 +283,16 @@ Unshare current session. [Learn more](/docs/share#un-sharing). --- +### update + +Check for a new version of OpenCode and install it. If a newer OpenCode 1 release is available, the dialog installs it. If OpenCode 1 is up to date, it offers to upgrade you to OpenCode 2.0 instead. OpenCode never installs updates on its own, so a notice on the home screen tells you when one is available. Choosing **Skip** on an OpenCode 1 update hides the notice until a newer version is released, and choosing **Dismiss** on the OpenCode 2.0 offer hides it for a week. + +```bash frame="none" +/update +``` + +--- + ## Editor setup Both the `/editor` and `/export` commands use the editor specified in your `EDITOR` environment variable.