Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 28dfd7b554 fix(tui): keep a stray Enter from upgrading v1 to v2
The update dialog opened unprompted with Confirm selected. The Enter that ended a first prompt ran `brew upgrade opencode` and installed 2.x. A 2.x release now shows a warning toast with the migration guide, once per version, and offers no upgrade. The 1.x dialog starts on Skip. Skipped 2.x versions from earlier releases no longer hide 1.x updates.

Refs #54066
2026-10-09 13:45:45 +05:30
Shoubhit Dash f75ba9ff9b test(tui): cover the update notice for v2 and 1.x releases
The first five tests fail on dev. Enter on a 2.x notice posts /global/upgrade, the 1.x dialog starts on Confirm, and a skipped 2.x release hides later 1.x updates.
2026-10-09 13:45:39 +05:30
3 changed files with 230 additions and 7 deletions

No files matched your search

+14 -2
View File
@@ -1033,15 +1033,27 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.properties.version
if (Number.parseInt(version.replace(/^v/, ""), 10) >= 2) {
if (kv.get("v2_notice_version") === version) return
kv.set("v2_notice_version", version)
toast.show({
variant: "warning",
title: "OpenCode v2 is available",
message: `v${version} is not compatible with v1, so OpenCode won't install it. Migration guide:\nhttps://opencode.ai/v2/docs/migrate-v1`,
duration: 15000,
})
return
}
const skipped = kv.get("skipped_version")
if (skipped && !isVersionGreater(version, skipped)) return
// Earlier releases also stored skipped 2.x versions here. Those must not hide 1.x updates.
if (skipped && Number.parseInt(skipped, 10) < 2 && !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",
{ cancelLabel: "skip", initial: "cancel" },
)
if (choice === false) {
+12 -5
View File
@@ -11,7 +11,8 @@ export type DialogConfirmProps = {
message: string
onConfirm?: () => void
onCancel?: () => void
label?: string
cancelLabel?: string
initial?: "confirm" | "cancel"
}
export type DialogConfirmResult = boolean | undefined
@@ -20,7 +21,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
active: props.initial ?? "confirm",
})
useBindings(() => ({
@@ -80,7 +81,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
}}
>
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
{Locale.titlecase(key === "cancel" ? (props.cancelLabel ?? key) : key)}
</text>
</box>
)}
@@ -90,7 +91,12 @@ export function DialogConfirm(props: DialogConfirmProps) {
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
DialogConfirm.show = (
dialog: DialogContext,
title: string,
message: string,
options?: Pick<DialogConfirmProps, "cancelLabel" | "initial">,
) => {
return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace(
() => (
@@ -99,7 +105,8 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string, lab
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
label={label}
cancelLabel={options?.cancelLabel}
initial={options?.initial}
/>
),
() => resolve(undefined),
+204
View File
@@ -0,0 +1,204 @@
import { expect, mock, test } from "bun:test"
import path from "node:path"
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { tmpdir } from "./fixture/fixture"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
const connectedProvider = { id: "test", name: "Test", source: "config", env: [], options: {}, models: {} }
async function startApp(state: string, kv: Record<string, unknown> = {}) {
await Bun.write(path.join(state, "kv.json"), JSON.stringify(kv))
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false })
const core = await import("@opentui/core")
await mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventSource()
const upgrades: { target: string }[] = []
const calls = createFetch()
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
if (url.pathname === "/global/upgrade") {
const body: { target: string } = await request.clone().json()
upgrades.push(body)
return json({ success: true, version: body.target })
}
if (url.pathname === "/config/providers") return json({ providers: [connectedProvider], default: {} })
return calls.fetch(request)
}) as typeof globalThis.fetch
let api!: TuiPluginApi
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch,
events: events.source,
args: {},
pluginHost: {
async start(input) {
api = input.api
started()
},
async dispose() {},
},
}).pipe(Effect.provide(Global.layerWith({ state }))),
)
const waitFor = async (predicate: () => boolean, what: string) => {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > 2000) throw new Error(`timed out waiting for ${what}`)
await Bun.sleep(10)
await setup.renderOnce()
}
}
await ready
await waitFor(() => api.kv.ready, "kv.ready")
return {
setup,
upgrades,
api,
waitFor,
announce(version: string) {
events.emit({
directory: "global",
payload: {
id: `evt_update_${version}`,
type: "installation.update-available",
properties: { version },
},
})
},
frame() {
return setup.captureCharFrame()
},
async settle() {
await Bun.sleep(100)
await setup.renderOnce()
},
async stop() {
api.keymap.dispatchCommand("app.exit")
await task
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
},
}
}
test("Enter on a v2 release notice does not upgrade", async () => {
await using tmp = await tmpdir()
const app = await startApp(tmp.path)
try {
app.announce("2.0.25")
await app.waitFor(() => app.frame().includes("v2.0.25"), "v2.0.25")
app.setup.mockInput.pressEnter()
await app.settle()
expect(app.upgrades).toEqual([])
expect(app.api.ui.dialog.open).toBe(false)
expect(app.frame()).toContain("OpenCode v2 is available")
expect(app.frame()).toContain("not compatible with v1")
expect(app.frame()).toContain("https://opencode.ai/v2/docs/migrate-v1")
expect(app.api.kv.get<string>("v2_notice_version")).toBe("2.0.25")
expect(app.api.kv.get("skipped_version")).toBeUndefined()
} finally {
await app.stop()
}
})
test("the update dialog starts on Skip", async () => {
await using tmp = await tmpdir()
const app = await startApp(tmp.path)
try {
app.announce("1.19.0")
await app.waitFor(() => app.frame().includes("update now?"), "update now?")
app.setup.mockInput.pressEnter()
await app.settle()
expect(app.upgrades).toEqual([])
expect(app.api.kv.get<string>("skipped_version")).toBe("1.19.0")
expect(app.api.ui.dialog.open).toBe(false)
} finally {
await app.stop()
}
})
test("choosing Confirm on the update dialog upgrades", async () => {
await using tmp = await tmpdir()
const app = await startApp(tmp.path)
try {
app.announce("1.19.0")
await app.waitFor(() => app.frame().includes("update now?"), "update now?")
app.setup.mockInput.pressArrow("right")
app.setup.mockInput.pressEnter()
await app.settle()
expect(app.upgrades).toEqual([{ target: "1.19.0" }])
} finally {
await app.stop()
}
})
test("each v2 release is announced once", async () => {
await using tmp = await tmpdir()
const app = await startApp(tmp.path, { v2_notice_version: "2.0.25" })
try {
app.announce("2.0.25")
await app.settle()
expect(app.frame()).not.toContain("v2.0.25")
expect(app.api.ui.dialog.open).toBe(false)
app.announce("2.0.26")
await app.waitFor(() => app.frame().includes("v2.0.26"), "v2.0.26")
expect(app.api.kv.get<string>("v2_notice_version")).toBe("2.0.26")
expect(app.upgrades).toEqual([])
} finally {
await app.stop()
}
})
test("a skipped v2 release does not hide 1.x updates", async () => {
await using tmp = await tmpdir()
const app = await startApp(tmp.path, { skipped_version: "2.0.25" })
try {
app.announce("1.19.0")
await app.waitFor(() => app.frame().includes("update now?"), "update now?")
expect(app.api.ui.dialog.open).toBe(true)
} finally {
await app.stop()
}
})
test("other confirm dialogs still start on Confirm", async () => {
await using tmp = await tmpdir()
const app = await startApp(tmp.path)
try {
const choices: string[] = []
app.api.ui.dialog.replace(() =>
app.api.ui.DialogConfirm({
title: "Plugin",
message: "Continue?",
onConfirm: () => choices.push("confirm"),
onCancel: () => choices.push("cancel"),
}),
)
await app.waitFor(() => app.frame().includes("Continue?"), "Continue?")
app.setup.mockInput.pressEnter()
await app.settle()
expect(choices).toEqual(["confirm"])
} finally {
await app.stop()
}
})