From 0da99b2dfd3734767d105e163a600692a1338add Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 10 Oct 2026 20:02:50 -0400 Subject: [PATCH 1/5] feat(tui): replace V1 auto-update with V2-style /update dialog --- packages/core/src/v1/config/config.ts | 2 +- packages/opencode/src/cli/cmd/tui.ts | 42 ++++- packages/opencode/src/cli/tui/worker.ts | 2 +- packages/opencode/src/cli/upgrade.ts | 63 +++---- .../__snapshots__/help-snapshots.test.ts.snap | 3 +- packages/opencode/test/cli/upgrade.test.ts | 29 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- packages/tui/src/app.tsx | 91 +++------- packages/tui/src/component/dialog-update.tsx | 170 ++++++++++++++++++ .../tui/src/context/update-notification.tsx | 106 +++++++++++ packages/tui/src/index.tsx | 1 + packages/tui/src/routes/home.tsx | 56 +++++- .../tui/test/component/dialog-update.test.ts | 28 +++ packages/web/src/content/docs/cli.mdx | 9 +- packages/web/src/content/docs/config.mdx | 7 +- packages/web/src/content/docs/tui.mdx | 10 ++ 17 files changed, 498 insertions(+), 125 deletions(-) create mode 100644 packages/opencode/test/cli/upgrade.test.ts create mode 100644 packages/tui/src/component/dialog-update.tsx create mode 100644 packages/tui/src/context/update-notification.tsx create mode 100644 packages/tui/test/component/dialog-update.test.ts 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..29e22b69d328 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 { 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: { type: "available"; version: string }) => void>() + let available: string | undefined + const check = () => + latest() + .then((version) => { + if (!version || version === available) return + available = version + listeners.forEach((notify) => notify({ type: "available", version })) + }) + .catch(() => {}) + setTimeout(check, 1000).unref?.() + setInterval(check, 10 * 60 * 1000).unref?.() + return { + subscribe: (notify) => { + if (available) notify({ type: "available", version: available }) + 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..c75134a99ce9 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -3,51 +3,34 @@ 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 +} 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 }, - }, + if ( + !shouldNotify({ + autoupdate: config.autoupdate, + disabled: Flag.OPENCODE_DISABLE_AUTOUPDATE, + always: Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE, + current: InstallationVersion, + 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 }, - }, - }) - 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 latest } 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..58a6d3aefe93 --- /dev/null +++ b/packages/tui/src/component/dialog-update.tsx @@ -0,0 +1,170 @@ +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" + +type Version = { current: string; latest?: string } + +// OpenCode 2 is always offered; the OpenCode 1 update only appears when a newer release exists. +export function updateTargets(version: Version | undefined): UpdateTarget[] { + const latest = version?.latest && version.latest !== version.current ? version.latest : undefined + if (!latest) return [{ type: "major" }] + return [{ type: "major" }, { type: "latest", version: latest }] +} + +export function DialogUpdate(props: { + check: () => Promise + state: () => UpdateState | undefined + install: (target: UpdateTarget) => Promise + skip: (version: string) => 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 latest = () => updateTargets(check()).find((target) => target.type === "latest") + 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 message = error() ?? (check()?.latest ? undefined : "Couldn't check for updates.") + if (message) return { type: "check-failed" as const, message } + const target = latest() + if (target) return { type: "available" as const, version: target.version } + return { type: "current" as const } + }) + const buttons = createMemo(() => { + const current = state() + if (current.type === "checking" || current.type === "installing") return [] + if (current.type === "installed") return [skip(), { label: "Restart", run: props.restart }] + const target = latest() + return [ + skip(), + { label: "OpenCode 2", run: () => props.install({ type: "major" }) }, + ...(target ? [{ label: "Update", run: () => props.install(target) }] : []), + ] + }) + + function skip() { + return { + label: "Skip", + run: () => { + const target = latest() + if (target && props.state()?.type !== "installed") props.skip(target.version) + dialog.clear() + }, + } + } + + 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 === "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…" + : `Installing OpenCode ${installing.target.version}…`} + + )} + + + {(installed) => ( + + {installed.target.type === "major" + ? "Update successful! A restart is required. Run `opencode` to start OpenCode 2." + : "Update successful! A restart is required."} + + )} + + + OpenCode is already up to date. + + + {(failed) => ( + + {failed.message} + + )} + + + )} + + button.label === "OpenCode 2")}> + + OpenCode 2 is also available. Upgrading installs OpenCode 2 and removes OpenCode 1. + + + + 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..e8a54efa1dde --- /dev/null +++ b/packages/tui/src/context/update-notification.tsx @@ -0,0 +1,106 @@ +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 } + +// Implemented by the CLI process so checks and installs run against this machine's installation. +export type UpdateSource = { + readonly subscribe: (notify: (notice: { readonly type: "available"; readonly version: string }) => void) => () => void + readonly check: () => Promise<{ readonly current: string; readonly latest?: string }> + readonly apply: (target: UpdateTarget) => Promise +} + +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() + + // Checks only announce a version. Installing always goes through /update. + onMount(() => { + if (!props.updater) return + onCleanup(props.updater.subscribe((notice) => setAvailable(notice.version))) + }) + + const notification = createMemo(() => { + const current = state() + if (current?.type === "installed") return current + const version = available() + if (!version) return undefined + const skipped = kv.get("skipped_version") + if (skipped && !isVersionGreater(version, skipped)) return undefined + return { type: "available" as const, version } + }) + + 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 manually, see ${MAJOR_INSTRUCTIONS}` + : message, + }) + }, + ) + } + + const open = () => { + const updater = props.updater + if (!updater) return + dialog.replace(() => ( + kv.set("skipped_version", version)} + 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..fa6c9e3837f0 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1 +1,2 @@ export { run, type TuiInput } from "./app" +export type { UpdateSource } from "./context/update-notification" diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4145e2d87be..9ac126bfab4c 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,45 @@ 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.target.type === "major" + ? " restart to use OpenCode 2" + : ` 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..ad0b946e8858 --- /dev/null +++ b/packages/tui/test/component/dialog-update.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { updateTargets } from "../../src/component/dialog-update" +import { isVersionGreater } from "../../src/context/update-notification" + +describe("dialog update", () => { + test("offers OpenCode 2 and the latest OpenCode 1 release when an update exists", () => { + expect(updateTargets({ current: "1.4.0", latest: "1.4.2" })).toEqual([ + { type: "major" }, + { type: "latest", version: "1.4.2" }, + ]) + }) + + test("only offers OpenCode 2 when OpenCode 1 is up to date", () => { + expect(updateTargets({ current: "1.4.2", latest: "1.4.2" })).toEqual([{ type: "major" }]) + }) + + test("still offers OpenCode 2 when the update check fails", () => { + expect(updateTargets({ current: "1.4.2" })).toEqual([{ type: "major" }]) + expect(updateTargets(undefined)).toEqual([{ type: "major" }]) + }) + + test("hides skipped versions until a newer one is released", () => { + 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..6f4d15b27022 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. The dialog shows the latest OpenCode 1 release and also lets you upgrade to OpenCode 2. OpenCode never installs updates on its own, so a notice on the home screen tells you when one is available. + +```bash frame="none" +/update +``` + +--- + ## Editor setup Both the `/editor` and `/export` commands use the editor specified in your `EDITOR` environment variable. From a39c56beab0564b4d4e0255c42977760e8b97cfa Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 10 Oct 2026 20:18:38 -0400 Subject: [PATCH 2/5] feat(tui): offer OpenCode 2 only when OpenCode 1 is up to date --- packages/opencode/src/cli/cmd/tui.ts | 18 +++--- packages/opencode/src/cli/upgrade.ts | 7 ++- packages/tui/src/component/dialog-update.tsx | 57 ++++++++++--------- .../tui/src/context/update-notification.tsx | 22 ++++--- packages/tui/src/index.tsx | 2 +- packages/tui/src/routes/home.tsx | 4 +- .../tui/test/component/dialog-update.test.ts | 16 ++---- packages/web/src/content/docs/tui.mdx | 2 +- 8 files changed, 65 insertions(+), 63 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 29e22b69d328..f84ffc428bad 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -10,7 +10,7 @@ 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 { UpdateSource } from "@opencode-ai/tui" +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" @@ -53,22 +53,22 @@ 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: { type: "available"; version: string }) => void>() - let available: string | undefined +function createUpdater(latest: () => Promise): UpdateSource { + const listeners = new Set<(notice: UpdateNotice) => void>() + let current: UpdateNotice | undefined const check = () => latest() - .then((version) => { - if (!version || version === available) return - available = version - listeners.forEach((notify) => notify({ type: "available", version })) + .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 (available) notify({ type: "available", version: available }) + if (current) notify(current) listeners.add(notify) return () => listeners.delete(notify) }, diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index c75134a99ce9..de009f719151 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -17,13 +17,14 @@ export function shouldNotify(input: { 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 latest = await Installation.latest(await Installation.method()).catch(() => {}) if (!latest) return if ( - !shouldNotify({ + shouldNotify({ autoupdate: config.autoupdate, disabled: Flag.OPENCODE_DISABLE_AUTOUPDATE, always: Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE, @@ -31,6 +32,6 @@ export async function upgrade() { latest, }) ) - return - return latest + return { type: "available" as const, version: latest } + return { type: "major" as const } } diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index 58a6d3aefe93..b3192ab72996 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -9,18 +9,17 @@ import { Spinner } from "./spinner" type Version = { current: string; latest?: string } -// OpenCode 2 is always offered; the OpenCode 1 update only appears when a newer release exists. -export function updateTargets(version: Version | undefined): UpdateTarget[] { - const latest = version?.latest && version.latest !== version.current ? version.latest : undefined - if (!latest) return [{ type: "major" }] - return [{ type: "major" }, { type: "latest", version: latest }] +// A newer OpenCode 1 release uses the normal update flow; OpenCode 2 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 + skip: (target: UpdateTarget) => void restart: () => void }) { const dialog = useDialog() @@ -34,36 +33,36 @@ export function DialogUpdate(props: { }), ) - const latest = () => updateTargets(check()).find((target) => target.type === "latest") + 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 message = error() ?? (check()?.latest ? undefined : "Couldn't check for updates.") - if (message) return { type: "check-failed" as const, message } - const target = latest() - if (target) return { type: "available" as const, version: target.version } - return { type: "current" as const } + 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 [] if (current.type === "installed") return [skip(), { label: "Restart", run: props.restart }] - const target = latest() - return [ - skip(), - { label: "OpenCode 2", run: () => props.install({ type: "major" }) }, - ...(target ? [{ label: "Update", run: () => props.install(target) }] : []), - ] + const next = target() + if (!next) return [skip()] + return [skip(), { label: next.type === "major" ? "Upgrade" : "Update", run: () => props.install(next) }] }) function skip() { return { label: "Skip", run: () => { - const target = latest() - if (target && props.state()?.type !== "installed") props.skip(target.version) + const next = target() + if (next && props.state()?.type !== "installed") props.skip(next) dialog.clear() }, } @@ -92,7 +91,7 @@ export function DialogUpdate(props: { {state().type === "installing" ? "Updating OpenCode" - : state().type === "available" || state().type === "failed" + : state().type === "available" || state().type === "major" || state().type === "failed" ? "Update available" : "Update"} @@ -130,8 +129,15 @@ export function DialogUpdate(props: { )} - - OpenCode is already up to date. + + + + OpenCode is already up to date. OpenCode 2 is available. + + + Upgrading installs OpenCode 2 and removes OpenCode 1. + + {(failed) => ( @@ -143,11 +149,6 @@ export function DialogUpdate(props: { )} - button.label === "OpenCode 2")}> - - OpenCode 2 is also available. Upgrading installs OpenCode 2 and removes OpenCode 1. - - 0}> diff --git a/packages/tui/src/context/update-notification.tsx b/packages/tui/src/context/update-notification.tsx index e8a54efa1dde..8b05ef1f4ab3 100644 --- a/packages/tui/src/context/update-notification.tsx +++ b/packages/tui/src/context/update-notification.tsx @@ -13,9 +13,12 @@ export type UpdateState = | { readonly type: "installed"; readonly target: UpdateTarget } | { readonly type: "failed"; readonly target: UpdateTarget; readonly message: string } +// A newer OpenCode 1 release wins; OpenCode 2 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: { readonly type: "available"; readonly version: string }) => void) => () => void + readonly subscribe: (notify: (notice: UpdateNotice) => void) => () => void readonly check: () => Promise<{ readonly current: string; readonly latest?: string }> readonly apply: (target: UpdateTarget) => Promise } @@ -28,23 +31,24 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider const kv = useKV() const dialog = useDialog() const exit = useExit() - const [available, setAvailable] = createSignal() + const [available, setAvailable] = createSignal() const [state, setState] = createSignal() // Checks only announce a version. Installing always goes through /update. onMount(() => { if (!props.updater) return - onCleanup(props.updater.subscribe((notice) => setAvailable(notice.version))) + onCleanup(props.updater.subscribe(setAvailable)) }) const notification = createMemo(() => { const current = state() if (current?.type === "installed") return current - const version = available() - if (!version) return undefined + const notice = available() + if (!notice) return undefined + if (notice.type === "major") return kv.get("skipped_major") ? undefined : notice const skipped = kv.get("skipped_version") - if (skipped && !isVersionGreater(version, skipped)) return undefined - return { type: "available" as const, version } + if (skipped && !isVersionGreater(notice.version, skipped)) return undefined + return notice }) const install = async (target: UpdateTarget) => { @@ -75,7 +79,9 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider check={updater.check} state={state} install={install} - skip={(version) => kv.set("skipped_version", version)} + skip={(target) => + target.type === "major" ? kv.set("skipped_major", true) : kv.set("skipped_version", target.version) + } restart={() => exit()} /> )) diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index fa6c9e3837f0..14780946b5d4 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1,2 +1,2 @@ export { run, type TuiInput } from "./app" -export type { UpdateSource } from "./context/update-notification" +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 9ac126bfab4c..e456d7fd0e43 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -136,7 +136,9 @@ function UpdateNotification(props: { width: number }) { {state.type === "installed" ? "/exit" : "/update"} {state.type === "available" ? ` to install v${state.version}` - : state.target.type === "major" + : state.type === "major" + ? " to upgrade to OpenCode 2" + : state.target.type === "major" ? " restart to use OpenCode 2" : ` 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 index ad0b946e8858..b95534cd3aed 100644 --- a/packages/tui/test/component/dialog-update.test.ts +++ b/packages/tui/test/component/dialog-update.test.ts @@ -1,22 +1,14 @@ import { describe, expect, test } from "bun:test" -import { updateTargets } from "../../src/component/dialog-update" +import { updateTarget } from "../../src/component/dialog-update" import { isVersionGreater } from "../../src/context/update-notification" describe("dialog update", () => { - test("offers OpenCode 2 and the latest OpenCode 1 release when an update exists", () => { - expect(updateTargets({ current: "1.4.0", latest: "1.4.2" })).toEqual([ - { type: "major" }, - { type: "latest", version: "1.4.2" }, - ]) + 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(updateTargets({ current: "1.4.2", latest: "1.4.2" })).toEqual([{ type: "major" }]) - }) - - test("still offers OpenCode 2 when the update check fails", () => { - expect(updateTargets({ current: "1.4.2" })).toEqual([{ type: "major" }]) - expect(updateTargets(undefined)).toEqual([{ type: "major" }]) + expect(updateTarget({ current: "1.4.2", latest: "1.4.2" })).toEqual({ type: "major" }) }) test("hides skipped versions until a newer one is released", () => { diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 6f4d15b27022..62bda82fba29 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -285,7 +285,7 @@ Unshare current session. [Learn more](/docs/share#un-sharing). ### update -Check for a new version of OpenCode and install it. The dialog shows the latest OpenCode 1 release and also lets you upgrade to OpenCode 2. OpenCode never installs updates on its own, so a notice on the home screen tells you when one is available. +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 instead. OpenCode never installs updates on its own, so a notice on the home screen tells you when one is available. ```bash frame="none" /update From 75121aae8c0aab47cc24aba082fcbb7b635712e4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 10 Oct 2026 20:27:28 -0400 Subject: [PATCH 3/5] feat(tui): link OpenCode 2.0 release notes and dismiss update notices for a week --- packages/tui/src/component/dialog-update.tsx | 29 ++++++----- .../tui/src/context/update-notification.tsx | 48 ++++++++----------- packages/tui/src/routes/home.tsx | 4 +- .../tui/test/component/dialog-update.test.ts | 15 +++--- packages/web/src/content/docs/tui.mdx | 2 +- 5 files changed, 48 insertions(+), 50 deletions(-) diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index b3192ab72996..da88a423b3d6 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -6,10 +6,13 @@ import { useDialog } from "../ui/dialog" import { useBindings } from "../keymap" import { errorMessage } from "../util/error" import { Spinner } from "./spinner" +import { Link } from "../ui/link" + +export const V2_ANNOUNCEMENT_URL = "https://opencode.ai/v2" type Version = { current: string; latest?: string } -// A newer OpenCode 1 release uses the normal update flow; OpenCode 2 is only offered when OpenCode 1 is current. +// 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" } @@ -19,7 +22,7 @@ export function DialogUpdate(props: { check: () => Promise state: () => UpdateState | undefined install: (target: UpdateTarget) => Promise - skip: (target: UpdateTarget) => void + dismiss: () => void restart: () => void }) { const dialog = useDialog() @@ -51,18 +54,17 @@ export function DialogUpdate(props: { const buttons = createMemo(() => { const current = state() if (current.type === "checking" || current.type === "installing") return [] - if (current.type === "installed") return [skip(), { label: "Restart", run: props.restart }] + if (current.type === "installed") return [dismiss(), { label: "Restart", run: props.restart }] const next = target() - if (!next) return [skip()] - return [skip(), { label: next.type === "major" ? "Upgrade" : "Update", run: () => props.install(next) }] + if (!next) return [dismiss()] + return [dismiss(), { label: next.type === "major" ? "Upgrade" : "Update", run: () => props.install(next) }] }) - function skip() { + function dismiss() { return { - label: "Skip", + label: "Dismiss", run: () => { - const next = target() - if (next && props.state()?.type !== "installed") props.skip(next) + if (props.state()?.type !== "installed") props.dismiss() dialog.clear() }, } @@ -115,7 +117,7 @@ export function DialogUpdate(props: { {(installing) => ( {installing.target.type === "major" - ? "Installing OpenCode 2…" + ? "Installing OpenCode 2.0…" : `Installing OpenCode ${installing.target.version}…`} )} @@ -124,7 +126,7 @@ export function DialogUpdate(props: { {(installed) => ( {installed.target.type === "major" - ? "Update successful! A restart is required. Run `opencode` to start OpenCode 2." + ? "Update successful! A restart is required. Run `opencode` to start OpenCode 2.0." : "Update successful! A restart is required."} )} @@ -132,10 +134,11 @@ export function DialogUpdate(props: { - OpenCode is already up to date. OpenCode 2 is available. + OpenCode 2.0 is the next major release. Read more about it: + - Upgrading installs OpenCode 2 and removes OpenCode 1. + It contains some breaking changes. diff --git a/packages/tui/src/context/update-notification.tsx b/packages/tui/src/context/update-notification.tsx index 8b05ef1f4ab3..b5380e83c79f 100644 --- a/packages/tui/src/context/update-notification.tsx +++ b/packages/tui/src/context/update-notification.tsx @@ -13,7 +13,7 @@ export type UpdateState = | { readonly type: "installed"; readonly target: UpdateTarget } | { readonly type: "failed"; readonly target: UpdateTarget; readonly message: string } -// A newer OpenCode 1 release wins; OpenCode 2 is only suggested once OpenCode 1 is up to date. +// 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. @@ -23,6 +23,15 @@ export type UpdateSource = { readonly apply: (target: UpdateTarget) => Promise } +export const DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000 + +// Dismissing hides the notice for a week, whichever update it was for. +export function visibleNotice(notice: UpdateNotice | undefined, dismissedUntil: number | undefined, now: number) { + if (!notice) return undefined + if (dismissedUntil !== undefined && now < dismissedUntil) return undefined + return notice +} + export const MAJOR_INSTRUCTIONS = "https://opencode.ai/v2/docs" export const { use: useUpdateNotification, provider: UpdateNotificationProvider } = createSimpleContext({ @@ -33,6 +42,9 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider 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(() => { @@ -43,12 +55,7 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider const notification = createMemo(() => { const current = state() if (current?.type === "installed") return current - const notice = available() - if (!notice) return undefined - if (notice.type === "major") return kv.get("skipped_major") ? undefined : notice - const skipped = kv.get("skipped_version") - if (skipped && !isVersionGreater(notice.version, skipped)) return undefined - return notice + return visibleNotice(available(), kv.get("update_dismissed_until"), now()) }) const install = async (target: UpdateTarget) => { @@ -64,7 +71,7 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider target, message: target.type === "major" && !message.includes(MAJOR_INSTRUCTIONS) - ? `${message}\nTo install OpenCode 2 manually, see ${MAJOR_INSTRUCTIONS}` + ? `${message}\nTo install OpenCode 2.0 manually, see ${MAJOR_INSTRUCTIONS}` : message, }) }, @@ -79,9 +86,11 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider check={updater.check} state={state} install={install} - skip={(target) => - target.type === "major" ? kv.set("skipped_major", true) : kv.set("skipped_version", target.version) - } + dismiss={() => { + const at = Date.now() + kv.set("update_dismissed_until", at + DISMISS_DURATION) + setNow(at) + }} restart={() => exit()} /> )) @@ -93,20 +102,3 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider } }, }) - -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/routes/home.tsx b/packages/tui/src/routes/home.tsx index e456d7fd0e43..344cfbebdbff 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -137,9 +137,9 @@ function UpdateNotification(props: { width: number }) { {state.type === "available" ? ` to install v${state.version}` : state.type === "major" - ? " to upgrade to OpenCode 2" + ? " to upgrade to OpenCode 2.0" : state.target.type === "major" - ? " restart to use OpenCode 2" + ? " 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 index b95534cd3aed..c670cd0f1a6b 100644 --- a/packages/tui/test/component/dialog-update.test.ts +++ b/packages/tui/test/component/dialog-update.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { updateTarget } from "../../src/component/dialog-update" -import { isVersionGreater } from "../../src/context/update-notification" +import { DISMISS_DURATION, visibleNotice } from "../../src/context/update-notification" describe("dialog update", () => { test("uses the normal OpenCode 1 update when a newer release exists", () => { @@ -11,10 +11,13 @@ describe("dialog update", () => { expect(updateTarget({ current: "1.4.2", latest: "1.4.2" })).toEqual({ type: "major" }) }) - test("hides skipped versions until a newer one is released", () => { - 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) + test("hides the notice for a week after it is dismissed", () => { + const notice = { type: "available" as const, version: "1.4.2" } + const now = 1_000_000 + expect(visibleNotice(notice, undefined, now)).toEqual(notice) + expect(visibleNotice(notice, now + DISMISS_DURATION, now)).toBeUndefined() + expect(visibleNotice({ type: "major" }, now + DISMISS_DURATION, now + DISMISS_DURATION - 1)).toBeUndefined() + expect(visibleNotice({ type: "major" }, now + DISMISS_DURATION, now + DISMISS_DURATION)).toEqual({ type: "major" }) + expect(DISMISS_DURATION).toBe(7 * 24 * 60 * 60 * 1000) }) }) diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 62bda82fba29..154b1e169d02 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -285,7 +285,7 @@ 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 instead. OpenCode never installs updates on its own, so a notice on the home screen tells you when one is available. +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 **Dismiss** hides the notice for a week. ```bash frame="none" /update From ddf8235af0dce370a30a66cfabe7129aad7df700 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 10 Oct 2026 20:40:31 -0400 Subject: [PATCH 4/5] feat(tui): skip V1 updates, dismiss the 2.0 offer for a week, inline release-notes link --- packages/tui/src/component/dialog-update.tsx | 41 +++++++++++-------- .../tui/src/context/update-notification.tsx | 36 ++++++++++++++-- .../tui/test/component/dialog-update.test.ts | 38 +++++++++++++---- packages/web/src/content/docs/tui.mdx | 2 +- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index da88a423b3d6..fbdbe71f542a 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -6,9 +6,9 @@ import { useDialog } from "../ui/dialog" import { useBindings } from "../keymap" import { errorMessage } from "../util/error" import { Spinner } from "./spinner" -import { Link } from "../ui/link" +import { openUrl } from "@opencode-ai/core/open" -export const V2_ANNOUNCEMENT_URL = "https://opencode.ai/v2" +export const V2_ANNOUNCEMENT_URL = "https://anoma.ly/notes/opencode-2-0" type Version = { current: string; latest?: string } @@ -22,6 +22,7 @@ export function DialogUpdate(props: { check: () => Promise state: () => UpdateState | undefined install: (target: UpdateTarget) => Promise + skip: (version: string) => void dismiss: () => void restart: () => void }) { @@ -54,23 +55,23 @@ export function DialogUpdate(props: { const buttons = createMemo(() => { const current = state() if (current.type === "checking" || current.type === "installing") return [] - if (current.type === "installed") return [dismiss(), { label: "Restart", run: props.restart }] const next = target() - if (!next) return [dismiss()] - return [dismiss(), { label: next.type === "major" ? "Upgrade" : "Update", run: () => props.install(next) }] - }) - - function dismiss() { - return { - label: "Dismiss", + const close = { + label: next?.type === "major" ? "Dismiss" : "Skip", run: () => { - if (props.state()?.type !== "installed") props.dismiss() + 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))) + // Wide enough to keep the release-notes link on the first line; the dialog still clamps to the terminal width. + createEffect(() => dialog.setSize(state().type === "major" ? "xlarge" : "medium")) const move = (offset: number) => { const count = buttons().length @@ -132,11 +133,19 @@ export function DialogUpdate(props: { )} - - - OpenCode 2.0 is the next major release. Read more about it: + + { + openUrl(V2_ANNOUNCEMENT_URL).catch(() => {}) + }} + > + OpenCode 2.0 is the next major release. Read more about it:{" "} + + {V2_ANNOUNCEMENT_URL} + - It contains some breaking changes. diff --git a/packages/tui/src/context/update-notification.tsx b/packages/tui/src/context/update-notification.tsx index b5380e83c79f..22f181ad7b16 100644 --- a/packages/tui/src/context/update-notification.tsx +++ b/packages/tui/src/context/update-notification.tsx @@ -25,10 +25,16 @@ export type UpdateSource = { export const DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000 -// Dismissing hides the notice for a week, whichever update it was for. -export function visibleNotice(notice: UpdateNotice | undefined, dismissedUntil: number | undefined, now: number) { +// 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 (dismissedUntil !== undefined && now < dismissedUntil) 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 } @@ -55,7 +61,11 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider const notification = createMemo(() => { const current = state() if (current?.type === "installed") return current - return visibleNotice(available(), kv.get("update_dismissed_until"), now()) + return visibleNotice( + available(), + { skippedVersion: kv.get("skipped_version"), dismissedUntil: kv.get("update_dismissed_until") }, + now(), + ) }) const install = async (target: UpdateTarget) => { @@ -86,6 +96,7 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider check={updater.check} state={state} install={install} + skip={(version) => kv.set("skipped_version", version)} dismiss={() => { const at = Date.now() kv.set("update_dismissed_until", at + DISMISS_DURATION) @@ -102,3 +113,20 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider } }, }) + +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/test/component/dialog-update.test.ts b/packages/tui/test/component/dialog-update.test.ts index c670cd0f1a6b..17535721bf5c 100644 --- a/packages/tui/test/component/dialog-update.test.ts +++ b/packages/tui/test/component/dialog-update.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { updateTarget } from "../../src/component/dialog-update" -import { DISMISS_DURATION, visibleNotice } from "../../src/context/update-notification" +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", () => { @@ -11,13 +11,37 @@ describe("dialog update", () => { expect(updateTarget({ current: "1.4.2", latest: "1.4.2" })).toEqual({ type: "major" }) }) - test("hides the notice for a week after it is dismissed", () => { - const notice = { type: "available" as const, version: "1.4.2" } + test("skipping an OpenCode 1 version hides it until a newer one ships", () => { const now = 1_000_000 - expect(visibleNotice(notice, undefined, now)).toEqual(notice) - expect(visibleNotice(notice, now + DISMISS_DURATION, now)).toBeUndefined() - expect(visibleNotice({ type: "major" }, now + DISMISS_DURATION, now + DISMISS_DURATION - 1)).toBeUndefined() - expect(visibleNotice({ type: "major" }, now + DISMISS_DURATION, now + DISMISS_DURATION)).toEqual({ type: "major" }) + 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/tui.mdx b/packages/web/src/content/docs/tui.mdx index 154b1e169d02..0351c46cde10 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -285,7 +285,7 @@ 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 **Dismiss** hides the notice for a week. +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 From 709ba73b5119a6fe4f91850287701a10089f36c5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 10 Oct 2026 20:43:56 -0400 Subject: [PATCH 5/5] fix(tui): keep the OpenCode 2.0 dialog at normal width --- packages/tui/src/component/dialog-update.tsx | 31 +++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index fbdbe71f542a..5461beafb45a 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -70,8 +70,6 @@ export function DialogUpdate(props: { }) createEffect(() => setActive(Math.max(0, buttons().length - 1))) - // Wide enough to keep the release-notes link on the first line; the dialog still clamps to the terminal width. - createEffect(() => dialog.setSize(state().type === "major" ? "xlarge" : "medium")) const move = (offset: number) => { const count = buttons().length @@ -134,18 +132,23 @@ export function DialogUpdate(props: { - { - openUrl(V2_ANNOUNCEMENT_URL).catch(() => {}) - }} - > - OpenCode 2.0 is the next major release. Read more about it:{" "} - - {V2_ANNOUNCEMENT_URL} - - + + + 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.