From e97e43c75746382c0533df0e341fbcc31842ef91 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 15 Mar 2026 18:01:38 -0700 Subject: [PATCH 1/6] feat: show update-available indicator in TUI footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a persistent, subtle upgrade indicator in both the home page and session footers. When a newer version of Altimate Code is available, the footer shows `current → latest · altimate upgrade` in muted/accent colors instead of just the version number. - Add `UpgradeIndicator` component and `getAvailableVersion` utility - Store available version in KV on `UpdateAvailable` event for persistence - Show indicator in both home and session footers - Fix toast message to say `altimate upgrade` instead of `opencode upgrade` - Add 24 tests covering version comparison, KV integration, and event flow Closes #173 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 + .../tui/component/upgrade-indicator-utils.ts | 9 ++ .../cmd/tui/component/upgrade-indicator.tsx | 28 ++++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 7 +- .../src/cli/cmd/tui/routes/session/footer.tsx | 2 + .../test/cli/tui/upgrade-indicator.test.ts | 58 +++++++ .../opencode/test/cli/upgrade-notify.test.ts | 151 ++++++++++++++++++ 7 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx create mode 100644 packages/opencode/test/cli/tui/upgrade-indicator.test.ts create mode 100644 packages/opencode/test/cli/upgrade-notify.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 93f0296361..ad4facc93c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -842,6 +842,7 @@ function App() { // altimate_change start — branding: altimate upgrade sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { + kv.set("update_available_version", evt.properties.version) toast.show({ variant: "info", title: "Update Available", diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts new file mode 100644 index 0000000000..3841dbf8bb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts @@ -0,0 +1,9 @@ +import { Installation } from "@/installation" + +export const UPGRADE_KV_KEY = "update_available_version" + +export function getAvailableVersion(kvValue: unknown): string | undefined { + if (typeof kvValue !== "string") return undefined + if (kvValue === Installation.VERSION) return undefined + return kvValue +} diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx new file mode 100644 index 0000000000..f9ad2d2e62 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx @@ -0,0 +1,28 @@ +import { createMemo, Show } from "solid-js" +import { useTheme } from "@tui/context/theme" +import { useKV } from "../context/kv" +import { Installation } from "@/installation" +import { UPGRADE_KV_KEY, getAvailableVersion } from "./upgrade-indicator-utils" + +export { UPGRADE_KV_KEY } from "./upgrade-indicator-utils" + +export function UpgradeIndicator() { + const { theme } = useTheme() + const kv = useKV() + + const latestVersion = createMemo(() => getAvailableVersion(kv.get(UPGRADE_KV_KEY))) + + return ( + + {(version) => ( + + + {Installation.VERSION} → {version()} + + · + altimate upgrade + + )} + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index e76e165b26..172c4aa418 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -15,6 +15,8 @@ import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" +import { UpgradeIndicator } from "../component/upgrade-indicator" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../component/upgrade-indicator-utils" // TODO: what is the best way to do this? let once = false @@ -152,7 +154,10 @@ export function Home() { - {Installation.VERSION} + + + {Installation.VERSION} + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 39d3e5f5d0..98cb87ebbe 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -8,6 +8,7 @@ import { useRoute } from "../../context/route" // altimate_change start - yolo mode visual indicator import { Flag } from "@/flag/flag" // altimate_change end +import { UpgradeIndicator } from "../../component/upgrade-indicator" export function Footer() { const { theme } = useTheme() @@ -95,6 +96,7 @@ export function Footer() { /status + ) diff --git a/packages/opencode/test/cli/tui/upgrade-indicator.test.ts b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts new file mode 100644 index 0000000000..074d986a72 --- /dev/null +++ b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../../../src/cli/cmd/tui/component/upgrade-indicator-utils" +import { Installation } from "../../../src/installation" + +describe("upgrade-indicator-utils", () => { + describe("UPGRADE_KV_KEY", () => { + test("exports a consistent KV key", () => { + expect(UPGRADE_KV_KEY).toBe("update_available_version") + }) + }) + + describe("getAvailableVersion", () => { + test("returns undefined when KV value is undefined", () => { + expect(getAvailableVersion(undefined)).toBeUndefined() + }) + + test("returns undefined when KV value is null", () => { + expect(getAvailableVersion(null)).toBeUndefined() + }) + + test("returns undefined when KV value is not a string", () => { + expect(getAvailableVersion(123)).toBeUndefined() + expect(getAvailableVersion(true)).toBeUndefined() + expect(getAvailableVersion({})).toBeUndefined() + expect(getAvailableVersion([])).toBeUndefined() + }) + + test("returns undefined when KV value matches current version", () => { + expect(getAvailableVersion(Installation.VERSION)).toBeUndefined() + }) + + test("returns version string when it differs from current version", () => { + const result = getAvailableVersion("99.99.99") + expect(result).toBe("99.99.99") + }) + + test("returns version for semver strings", () => { + const versions = ["0.1.0", "1.0.0", "2.0.0-beta.1", "99.0.0"] + for (const v of versions) { + if (v === Installation.VERSION) continue + expect(getAvailableVersion(v)).toBe(v) + } + }) + + test("returns undefined for empty string", () => { + // empty string is falsy, but typeof is "string" — it should still return undefined + // because empty version is not a valid update target + const result = getAvailableVersion("") + // empty string matches Installation.VERSION only if VERSION is also empty + if (Installation.VERSION === "") { + expect(result).toBeUndefined() + } else { + // empty string is a valid string but not a meaningful version + expect(result).toBe("") + } + }) + }) +}) diff --git a/packages/opencode/test/cli/upgrade-notify.test.ts b/packages/opencode/test/cli/upgrade-notify.test.ts new file mode 100644 index 0000000000..6a9a3b2d4f --- /dev/null +++ b/packages/opencode/test/cli/upgrade-notify.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Installation } from "../../src/installation" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../../src/cli/cmd/tui/component/upgrade-indicator-utils" + +const fetch0 = globalThis.fetch + +afterEach(() => { + globalThis.fetch = fetch0 +}) + +describe("upgrade notification flow", () => { + describe("event definitions", () => { + test("UpdateAvailable has correct event type", () => { + expect(Installation.Event.UpdateAvailable.type).toBe("installation.update-available") + }) + + test("Updated has correct event type", () => { + expect(Installation.Event.Updated.type).toBe("installation.updated") + }) + + test("UpdateAvailable schema validates version string", () => { + const result = Installation.Event.UpdateAvailable.properties.safeParse({ version: "1.2.3" }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.version).toBe("1.2.3") + } + }) + + test("UpdateAvailable schema rejects missing version", () => { + const result = Installation.Event.UpdateAvailable.properties.safeParse({}) + expect(result.success).toBe(false) + }) + + test("UpdateAvailable schema rejects non-string version", () => { + const result = Installation.Event.UpdateAvailable.properties.safeParse({ version: 123 }) + expect(result.success).toBe(false) + }) + }) + + describe("Installation.VERSION", () => { + test("is a non-empty string", () => { + expect(typeof Installation.VERSION).toBe("string") + expect(Installation.VERSION.length).toBeGreaterThan(0) + }) + }) + + describe("latest version fetch", () => { + test("returns version from GitHub releases for unknown method", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ tag_name: "v5.0.0" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + + const latest = await Installation.latest("unknown") + expect(latest).toBe("5.0.0") + }) + + test("strips v prefix from GitHub tag", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ tag_name: "v10.20.30" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + + const latest = await Installation.latest("unknown") + expect(latest).toBe("10.20.30") + }) + + test("returns npm version for npm method", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ version: "4.0.0" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + + const latest = await Installation.latest("npm") + expect(latest).toBe("4.0.0") + }) + }) +}) + +describe("KV-based upgrade indicator integration", () => { + test("UPGRADE_KV_KEY is consistent", () => { + expect(UPGRADE_KV_KEY).toBe("update_available_version") + }) + + test("simulated KV store correctly tracks update version", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "2.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("2.0.0") + }) + + test("indicator hidden after upgrade (version matches)", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "2.0.0" + + // Simulate: after upgrade, current version = stored version + const shouldShow = getAvailableVersion(store[UPGRADE_KV_KEY]) + // This test is version-dependent; use 2.0.0 which won't match Installation.VERSION + if (Installation.VERSION === "2.0.0") { + expect(shouldShow).toBeUndefined() + } else { + expect(shouldShow).toBe("2.0.0") + } + }) + + test("indicator shown when stored version differs from current", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "999.0.0" + + const result = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(result).toBe("999.0.0") + }) + + test("indicator hidden when key is absent", () => { + const store: Record = {} + const result = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(result).toBeUndefined() + }) + + test("KV value can be overwritten with newer version", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "2.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("2.0.0") + + store[UPGRADE_KV_KEY] = "3.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("3.0.0") + + const result = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(result).toBe("3.0.0") + }) + + test("end-to-end: event → KV → indicator flow", () => { + const store: Record = {} + + // Step 1: Simulate UpdateAvailable event handler storing version + const eventVersion = "5.0.0" + store[UPGRADE_KV_KEY] = eventVersion + + // Step 2: Verify indicator reads correctly + const displayVersion = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(displayVersion).toBe("5.0.0") + + // Step 3: After upgrade, clear or match version + // Simulate user upgraded — now VERSION would be "5.0.0" + // We can't change Installation.VERSION at runtime, so verify logic: + const shouldHideAfterUpgrade = eventVersion === eventVersion // same version = hide + expect(shouldHideAfterUpgrade).toBe(true) + }) +}) From 1b86affaed5b319ca172e2f80faa61838b302070 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 12:14:48 -0700 Subject: [PATCH 2/6] fix: address bot review findings for upgrade indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: Clear KV on `Updated` event — set `update_available_version` to current `VERSION` after autoupgrade so indicator hides immediately - F2: Add semver comparison via `isNewer()` — prevents downgrade arrow when user upgrades externally past the stored version - F3: Reject empty string as invalid version in `getAvailableVersion()` - Update tests to cover all three fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 ++ .../tui/component/upgrade-indicator-utils.ts | 18 ++++++- .../test/cli/tui/upgrade-indicator.test.ts | 32 +++++------ .../opencode/test/cli/upgrade-notify.test.ts | 53 ++++++++----------- 4 files changed, 58 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ad4facc93c..e6f40ebc34 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -850,6 +850,10 @@ function App() { duration: 10000, }) }) + + sdk.event.on(Installation.Event.Updated.type, () => { + kv.set("update_available_version", Installation.VERSION) + }) // altimate_change end return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts index 3841dbf8bb..c2bb4bfd70 100644 --- a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts @@ -2,8 +2,24 @@ import { Installation } from "@/installation" export const UPGRADE_KV_KEY = "update_available_version" +function isNewer(candidate: string, current: string): boolean { + const parse = (v: string) => v.split(".").map(Number) + const c = parse(candidate) + const cur = parse(current) + // If either fails to parse as semver, skip comparison and show the indicator + if (c.some(isNaN) || cur.some(isNaN)) return true + for (let i = 0; i < Math.max(c.length, cur.length); i++) { + const a = c[i] ?? 0 + const b = cur[i] ?? 0 + if (a > b) return true + if (a < b) return false + } + return false +} + export function getAvailableVersion(kvValue: unknown): string | undefined { - if (typeof kvValue !== "string") return undefined + if (typeof kvValue !== "string" || !kvValue) return undefined if (kvValue === Installation.VERSION) return undefined + if (!isNewer(kvValue, Installation.VERSION)) return undefined return kvValue } diff --git a/packages/opencode/test/cli/tui/upgrade-indicator.test.ts b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts index 074d986a72..2aa2f1dbab 100644 --- a/packages/opencode/test/cli/tui/upgrade-indicator.test.ts +++ b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts @@ -29,29 +29,29 @@ describe("upgrade-indicator-utils", () => { expect(getAvailableVersion(Installation.VERSION)).toBeUndefined() }) - test("returns version string when it differs from current version", () => { + test("returns version string when it is newer than current version", () => { const result = getAvailableVersion("99.99.99") expect(result).toBe("99.99.99") }) - test("returns version for semver strings", () => { - const versions = ["0.1.0", "1.0.0", "2.0.0-beta.1", "99.0.0"] - for (const v of versions) { - if (v === Installation.VERSION) continue - expect(getAvailableVersion(v)).toBe(v) - } + test("returns undefined for empty string", () => { + expect(getAvailableVersion("")).toBeUndefined() }) - test("returns undefined for empty string", () => { - // empty string is falsy, but typeof is "string" — it should still return undefined - // because empty version is not a valid update target - const result = getAvailableVersion("") - // empty string matches Installation.VERSION only if VERSION is also empty - if (Installation.VERSION === "") { - expect(result).toBeUndefined() + test("returns version when stored version is newer or unparseable (dev mode)", () => { + // In dev mode VERSION="local", semver parsing falls back to showing indicator + const result = getAvailableVersion("999.0.0") + expect(typeof result === "string" || result === undefined).toBe(true) + }) + + test("returns version for any valid semver in dev mode", () => { + // When VERSION="local" (dev), isNewer returns true for any candidate + // When VERSION is semver, only truly newer versions pass + const result = getAvailableVersion("0.0.1") + if (Installation.VERSION === "local") { + expect(result).toBe("0.0.1") } else { - // empty string is a valid string but not a meaningful version - expect(result).toBe("") + expect(result).toBeUndefined() } }) }) diff --git a/packages/opencode/test/cli/upgrade-notify.test.ts b/packages/opencode/test/cli/upgrade-notify.test.ts index 6a9a3b2d4f..7cc499498c 100644 --- a/packages/opencode/test/cli/upgrade-notify.test.ts +++ b/packages/opencode/test/cli/upgrade-notify.test.ts @@ -87,25 +87,22 @@ describe("KV-based upgrade indicator integration", () => { test("simulated KV store correctly tracks update version", () => { const store: Record = {} - store[UPGRADE_KV_KEY] = "2.0.0" - expect(store[UPGRADE_KV_KEY]).toBe("2.0.0") + store[UPGRADE_KV_KEY] = "999.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("999.0.0") }) - test("indicator hidden after upgrade (version matches)", () => { - const store: Record = {} - store[UPGRADE_KV_KEY] = "2.0.0" - - // Simulate: after upgrade, current version = stored version - const shouldShow = getAvailableVersion(store[UPGRADE_KV_KEY]) - // This test is version-dependent; use 2.0.0 which won't match Installation.VERSION - if (Installation.VERSION === "2.0.0") { - expect(shouldShow).toBeUndefined() + test("indicator hidden when stored version is older (prevents downgrade arrow)", () => { + // F2 fix: user on 0.5.3, KV has stale "0.5.0" → should NOT show downgrade + // In dev mode (VERSION="local"), semver parsing can't compare, so indicator shows + const result = getAvailableVersion("0.5.0") + if (Installation.VERSION === "local") { + expect(result).toBe("0.5.0") } else { - expect(shouldShow).toBe("2.0.0") + expect(result).toBeUndefined() } }) - test("indicator shown when stored version differs from current", () => { + test("indicator shown when stored version is newer than current", () => { const store: Record = {} store[UPGRADE_KV_KEY] = "999.0.0" @@ -121,31 +118,23 @@ describe("KV-based upgrade indicator integration", () => { test("KV value can be overwritten with newer version", () => { const store: Record = {} - store[UPGRADE_KV_KEY] = "2.0.0" - expect(store[UPGRADE_KV_KEY]).toBe("2.0.0") - - store[UPGRADE_KV_KEY] = "3.0.0" - expect(store[UPGRADE_KV_KEY]).toBe("3.0.0") + store[UPGRADE_KV_KEY] = "998.0.0" + store[UPGRADE_KV_KEY] = "999.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("999.0.0") const result = getAvailableVersion(store[UPGRADE_KV_KEY]) - expect(result).toBe("3.0.0") + expect(result).toBe("999.0.0") }) - test("end-to-end: event → KV → indicator flow", () => { + test("end-to-end: event → KV → indicator → reset on Updated", () => { const store: Record = {} - // Step 1: Simulate UpdateAvailable event handler storing version - const eventVersion = "5.0.0" - store[UPGRADE_KV_KEY] = eventVersion - - // Step 2: Verify indicator reads correctly - const displayVersion = getAvailableVersion(store[UPGRADE_KV_KEY]) - expect(displayVersion).toBe("5.0.0") + // Step 1: UpdateAvailable event stores version + store[UPGRADE_KV_KEY] = "999.0.0" + expect(getAvailableVersion(store[UPGRADE_KV_KEY])).toBe("999.0.0") - // Step 3: After upgrade, clear or match version - // Simulate user upgraded — now VERSION would be "5.0.0" - // We can't change Installation.VERSION at runtime, so verify logic: - const shouldHideAfterUpgrade = eventVersion === eventVersion // same version = hide - expect(shouldHideAfterUpgrade).toBe(true) + // Step 2: Updated event sets KV to current version (F1 fix) + store[UPGRADE_KV_KEY] = Installation.VERSION + expect(getAvailableVersion(store[UPGRADE_KV_KEY])).toBeUndefined() }) }) From 52eaacbce2a4a3566063deefce8ce6a06f8d7ebe Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 12:31:45 -0700 Subject: [PATCH 3/6] fix: address code review findings for upgrade indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom `isNewer` with `semver.gt()` — `semver` is already a dependency; handles prerelease tags and rejects corrupted KV values - Use `UPGRADE_KV_KEY` constant in `app.tsx` instead of magic string - Add `fallback` prop to `UpgradeIndicator` — eliminates duplicate `getAvailableVersion` call in `home.tsx` (Gemini design suggestion) - Remove unused `UPGRADE_KV_KEY` re-export from `upgrade-indicator.tsx` - Add conditional check before `kv.set` on `Updated` event to avoid unnecessary file writes - Fix tautological test, add tests for corrupted/prerelease versions Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/tui/app.tsx | 7 ++++-- .../tui/component/upgrade-indicator-utils.ts | 19 +++++++-------- .../cmd/tui/component/upgrade-indicator.tsx | 17 +++++-------- .../opencode/src/cli/cmd/tui/routes/home.tsx | 6 +---- .../test/cli/tui/upgrade-indicator.test.ts | 24 +++++++++++++------ .../opencode/test/cli/upgrade-notify.test.ts | 9 +++++-- 6 files changed, 44 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e6f40ebc34..b7b75a0756 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -6,6 +6,7 @@ import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Installation } from "@/installation" +import { UPGRADE_KV_KEY } from "./component/upgrade-indicator-utils" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -842,7 +843,7 @@ function App() { // altimate_change start — branding: altimate upgrade sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { - kv.set("update_available_version", evt.properties.version) + kv.set(UPGRADE_KV_KEY, evt.properties.version) toast.show({ variant: "info", title: "Update Available", @@ -852,7 +853,9 @@ function App() { }) sdk.event.on(Installation.Event.Updated.type, () => { - kv.set("update_available_version", Installation.VERSION) + if (kv.get(UPGRADE_KV_KEY) !== Installation.VERSION) { + kv.set(UPGRADE_KV_KEY, Installation.VERSION) + } }) // altimate_change end diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts index c2bb4bfd70..32e86df130 100644 --- a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts @@ -1,20 +1,17 @@ +import semver from "semver" import { Installation } from "@/installation" export const UPGRADE_KV_KEY = "update_available_version" function isNewer(candidate: string, current: string): boolean { - const parse = (v: string) => v.split(".").map(Number) - const c = parse(candidate) - const cur = parse(current) - // If either fails to parse as semver, skip comparison and show the indicator - if (c.some(isNaN) || cur.some(isNaN)) return true - for (let i = 0; i < Math.max(c.length, cur.length); i++) { - const a = c[i] ?? 0 - const b = cur[i] ?? 0 - if (a > b) return true - if (a < b) return false + // Dev mode: show indicator for any valid semver candidate + if (current === "local") { + return semver.valid(candidate) !== null } - return false + if (!semver.valid(candidate) || !semver.valid(current)) { + return false + } + return semver.gt(candidate, current) } export function getAvailableVersion(kvValue: unknown): string | undefined { diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx index f9ad2d2e62..be075096c7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx @@ -1,26 +1,21 @@ -import { createMemo, Show } from "solid-js" +import { createMemo, Show, type JSX } from "solid-js" import { useTheme } from "@tui/context/theme" import { useKV } from "../context/kv" -import { Installation } from "@/installation" import { UPGRADE_KV_KEY, getAvailableVersion } from "./upgrade-indicator-utils" -export { UPGRADE_KV_KEY } from "./upgrade-indicator-utils" - -export function UpgradeIndicator() { +export function UpgradeIndicator(props: { fallback?: JSX.Element }) { const { theme } = useTheme() const kv = useKV() const latestVersion = createMemo(() => getAvailableVersion(kv.get(UPGRADE_KV_KEY))) return ( - + {(version) => ( - - {Installation.VERSION} → {version()} - - · - altimate upgrade + + {version()} + available · altimate upgrade )} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 172c4aa418..54a0e47aa2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -16,7 +16,6 @@ import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" import { UpgradeIndicator } from "../component/upgrade-indicator" -import { UPGRADE_KV_KEY, getAvailableVersion } from "../component/upgrade-indicator-utils" // TODO: what is the best way to do this? let once = false @@ -154,10 +153,7 @@ export function Home() { - - - {Installation.VERSION} - + {Installation.VERSION}} /> diff --git a/packages/opencode/test/cli/tui/upgrade-indicator.test.ts b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts index 2aa2f1dbab..cca2b2ee88 100644 --- a/packages/opencode/test/cli/tui/upgrade-indicator.test.ts +++ b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts @@ -38,15 +38,25 @@ describe("upgrade-indicator-utils", () => { expect(getAvailableVersion("")).toBeUndefined() }) - test("returns version when stored version is newer or unparseable (dev mode)", () => { - // In dev mode VERSION="local", semver parsing falls back to showing indicator - const result = getAvailableVersion("999.0.0") - expect(typeof result === "string" || result === undefined).toBe(true) + test("returns undefined for invalid/corrupted version strings", () => { + // Invalid versions should not show the indicator (semver rejects them) + expect(getAvailableVersion("not-a-version")).toBeUndefined() + expect(getAvailableVersion("error")).toBeUndefined() }) - test("returns version for any valid semver in dev mode", () => { - // When VERSION="local" (dev), isNewer returns true for any candidate - // When VERSION is semver, only truly newer versions pass + test("handles prerelease versions correctly", () => { + // Prerelease of a very high version should still show + const result = getAvailableVersion("99.0.0-beta.1") + if (Installation.VERSION === "local") { + expect(result).toBe("99.0.0-beta.1") + } else { + // semver.gt handles prerelease correctly + expect(typeof result === "string" || result === undefined).toBe(true) + } + }) + + test("returns version for valid semver in dev mode", () => { + // When VERSION="local" (dev), any valid semver candidate shows const result = getAvailableVersion("0.0.1") if (Installation.VERSION === "local") { expect(result).toBe("0.0.1") diff --git a/packages/opencode/test/cli/upgrade-notify.test.ts b/packages/opencode/test/cli/upgrade-notify.test.ts index 7cc499498c..58b19e174b 100644 --- a/packages/opencode/test/cli/upgrade-notify.test.ts +++ b/packages/opencode/test/cli/upgrade-notify.test.ts @@ -92,8 +92,8 @@ describe("KV-based upgrade indicator integration", () => { }) test("indicator hidden when stored version is older (prevents downgrade arrow)", () => { - // F2 fix: user on 0.5.3, KV has stale "0.5.0" → should NOT show downgrade - // In dev mode (VERSION="local"), semver parsing can't compare, so indicator shows + // F2 fix: user on 0.5.3, KV has stale "0.5.0" — should NOT show downgrade + // In dev mode (VERSION="local"), valid semver candidates still show const result = getAvailableVersion("0.5.0") if (Installation.VERSION === "local") { expect(result).toBe("0.5.0") @@ -102,6 +102,11 @@ describe("KV-based upgrade indicator integration", () => { } }) + test("indicator hidden for invalid/corrupted KV values", () => { + expect(getAvailableVersion("corrupted")).toBeUndefined() + expect(getAvailableVersion("not-semver")).toBeUndefined() + }) + test("indicator shown when stored version is newer than current", () => { const store: Record = {} store[UPGRADE_KV_KEY] = "999.0.0" From 32fce2f3ec83e5928cb86bfddf903a887ef4d842 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 12:36:27 -0700 Subject: [PATCH 4/6] test: add e2e tests for upgrade indicator lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 21 end-to-end tests covering the full upgrade indicator flow: - Full lifecycle: fresh install → UpdateAvailable → indicator shown → Updated → indicator hidden - F1 regression: stale indicator after autoupgrade is cleared - F2 regression: downgrade arrow prevention with `semver.gt()` - F3 regression: empty/corrupted/non-string KV values rejected - Semver integration: prerelease, build metadata, v-prefix handling - Edge cases: rapid events, Updated without UpdateAvailable, same version available as current Uses mock KV store pattern consistent with codebase test conventions (algorithm extraction, no Solid.js context required). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/tui/upgrade-indicator-e2e.test.ts | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts diff --git a/packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts b/packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts new file mode 100644 index 0000000000..deb8ba5e63 --- /dev/null +++ b/packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, test } from "bun:test" +import semver from "semver" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../../../src/cli/cmd/tui/component/upgrade-indicator-utils" +import { Installation } from "../../../src/installation" + +/** + * End-to-end tests for the upgrade indicator feature. + * + * These simulate the full lifecycle: + * UpdateAvailable event → KV store → getAvailableVersion → indicator visibility + * Updated event → KV reset → indicator hidden + * + * Regression tests for the three original bot findings: + * F1: Stale indicator after autoupgrade (KV not cleared on Updated event) + * F2: Downgrade arrow (KV has older version than current) + * F3: Empty string leaks as valid version + * + * Also covers the semver library integration (replacing custom isNewer). + */ + +// ─── KV Store Simulation ────────────────────────────────────────────────────── +// Simulates the KV store behavior from context/kv.tsx without Solid.js context. +// The real KV store uses createStore + Filesystem.writeJson; we simulate the +// get/set interface with a plain object. + +function createMockKV() { + const store: Record = {} + return { + get(key: string, defaultValue?: any) { + return store[key] ?? defaultValue + }, + set(key: string, value: any) { + store[key] = value + }, + raw: store, + } +} + +// ─── Event Handler Simulation ───────────────────────────────────────────────── +// Mirrors the event handlers in app.tsx:843-857 + +function simulateUpdateAvailableEvent(kv: ReturnType, version: string) { + kv.set(UPGRADE_KV_KEY, version) +} + +function simulateUpdatedEvent(kv: ReturnType) { + if (kv.get(UPGRADE_KV_KEY) !== Installation.VERSION) { + kv.set(UPGRADE_KV_KEY, Installation.VERSION) + } +} + +// ─── Full Lifecycle E2E Tests ───────────────────────────────────────────────── + +describe("upgrade indicator e2e: full lifecycle", () => { + test("fresh install: no indicator shown", () => { + const kv = createMockKV() + // No events fired yet — KV has no update_available_version key + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(version).toBeUndefined() + }) + + test("UpdateAvailable → indicator shown → user sees upgrade prompt", () => { + const kv = createMockKV() + + // Step 1: Server publishes UpdateAvailable with newer version + simulateUpdateAvailableEvent(kv, "999.0.0") + + // Step 2: Indicator should show the new version + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(version).toBe("999.0.0") + }) + + test("UpdateAvailable → user upgrades → Updated event → indicator hidden", () => { + const kv = createMockKV() + + // Step 1: Update available + simulateUpdateAvailableEvent(kv, "999.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0") + + // Step 2: User runs `altimate upgrade`, Updated event fires + simulateUpdatedEvent(kv) + + // Step 3: Indicator should be hidden (KV now matches VERSION) + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("multiple UpdateAvailable events: latest version wins", () => { + const kv = createMockKV() + + simulateUpdateAvailableEvent(kv, "998.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("998.0.0") + + simulateUpdateAvailableEvent(kv, "999.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0") + }) + + test("KV persists across route changes (simulated)", () => { + const kv = createMockKV() + + // UpdateAvailable fires on home page + simulateUpdateAvailableEvent(kv, "999.0.0") + + // User navigates to session — same KV, indicator still shows + const versionOnSession = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(versionOnSession).toBe("999.0.0") + + // User navigates back to home — still there + const versionOnHome = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(versionOnHome).toBe("999.0.0") + }) +}) + +// ─── Regression: F1 — Stale indicator after autoupgrade ─────────────────────── + +describe("upgrade indicator e2e: F1 regression — stale after autoupgrade", () => { + test("autoupgrade completes → Updated event clears indicator", () => { + const kv = createMockKV() + + // UpdateAvailable fires + simulateUpdateAvailableEvent(kv, "999.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0") + + // Autoupgrade succeeds, Updated event fires + simulateUpdatedEvent(kv) + + // Indicator must be hidden — the bug was that Updated wasn't handled + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("Updated event is idempotent (no unnecessary KV writes)", () => { + const kv = createMockKV() + + // Already at current version — Updated should not write + kv.set(UPGRADE_KV_KEY, Installation.VERSION) + const before = kv.get(UPGRADE_KV_KEY) + + simulateUpdatedEvent(kv) + + // Value unchanged — conditional check prevented redundant write + expect(kv.get(UPGRADE_KV_KEY)).toBe(before) + }) +}) + +// ─── Regression: F2 — Downgrade arrow ───────────────────────────────────────── + +describe("upgrade indicator e2e: F2 regression — downgrade arrow prevention", () => { + test("stale KV with older version does not show downgrade indicator", () => { + const kv = createMockKV() + + // Scenario: user on 0.5.3, KV has stale "0.5.0" from before external upgrade + kv.set(UPGRADE_KV_KEY, "0.5.0") + + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + + if (Installation.VERSION === "local") { + // Dev mode: semver.valid("0.5.0") is valid, so indicator shows + expect(version).toBe("0.5.0") + } else { + // Production: 0.5.0 is NOT newer than current VERSION → hidden + expect(version).toBeUndefined() + } + }) + + test("user upgrades externally past stored version", () => { + const kv = createMockKV() + + // UpdateAvailable stored "1.0.0", user upgrades to "2.0.0" externally + // On restart, VERSION is "2.0.0" but KV still has "1.0.0" + kv.set(UPGRADE_KV_KEY, "1.0.0") + + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + + if (Installation.VERSION === "local") { + expect(version).toBe("1.0.0") + } else { + // 1.0.0 is NOT newer than current → should NOT show + const current = semver.valid(Installation.VERSION) + if (current && semver.gt("1.0.0", current)) { + expect(version).toBe("1.0.0") + } else { + expect(version).toBeUndefined() + } + } + }) + + test("only truly newer versions show the indicator", () => { + // This test only makes sense in production (VERSION is semver) + if (Installation.VERSION === "local") return + + const current = semver.valid(Installation.VERSION) + if (!current) return + + // Older version — should NOT show + const older = semver.valid("0.0.1")! + expect(getAvailableVersion(older)).toBeUndefined() + + // Same version — should NOT show + expect(getAvailableVersion(current)).toBeUndefined() + + // Newer version — SHOULD show + const newer = semver.inc(current, "patch")! + expect(getAvailableVersion(newer)).toBe(newer) + }) +}) + +// ─── Regression: F3 — Empty string leak ─────────────────────────────────────── + +describe("upgrade indicator e2e: F3 regression — empty/invalid value handling", () => { + test("empty string in KV does not show indicator", () => { + const kv = createMockKV() + kv.set(UPGRADE_KV_KEY, "") + + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("corrupted KV value does not show indicator", () => { + const kv = createMockKV() + + const corrupted = ["error", "null", "undefined", "not-a-version", "{}", "[]", "v", ".."] + for (const value of corrupted) { + kv.set(UPGRADE_KV_KEY, value) + const result = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(result).toBeUndefined() + } + }) + + test("non-string KV values do not show indicator", () => { + const kv = createMockKV() + + const invalid = [null, undefined, 123, true, false, {}, [], NaN] + for (const value of invalid) { + kv.raw[UPGRADE_KV_KEY] = value + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + } + }) +}) + +// ─── Semver Integration Tests ───────────────────────────────────────────────── + +describe("upgrade indicator e2e: semver integration", () => { + test("prerelease versions are handled correctly", () => { + // Prerelease of a very high version + const result = getAvailableVersion("99.0.0-beta.1") + if (Installation.VERSION === "local") { + // Dev mode: semver.valid("99.0.0-beta.1") is valid + expect(result).toBe("99.0.0-beta.1") + } else { + // Production: prerelease is lower than release + // "99.0.0-beta.1" < "99.0.0" but still > most current versions + const current = semver.valid(Installation.VERSION) + if (current && semver.gt("99.0.0-beta.1", current)) { + expect(result).toBe("99.0.0-beta.1") + } else { + expect(result).toBeUndefined() + } + } + }) + + test("build metadata versions are handled", () => { + // semver ignores build metadata in comparisons + const result = getAvailableVersion("999.0.0+build.123") + // semver.valid("999.0.0+build.123") returns "999.0.0+build.123" + if (semver.valid("999.0.0+build.123")) { + expect(result).toBe("999.0.0+build.123") + } else { + expect(result).toBeUndefined() + } + }) + + test("v-prefixed versions are accepted (semver strips the prefix)", () => { + // semver.valid("v99.0.0") returns "99.0.0" — it normalizes the v prefix + const result = getAvailableVersion("v99.0.0") + expect(result).toBe("v99.0.0") + }) + + test("dev mode shows indicator for any valid semver", () => { + if (Installation.VERSION !== "local") return + + // In dev mode, any valid semver candidate should show + const validVersions = ["0.0.1", "1.0.0", "99.99.99", "1.0.0-alpha.1"] + for (const v of validVersions) { + expect(getAvailableVersion(v)).toBe(v) + } + + // Invalid semver should NOT show even in dev mode + const invalidVersions = ["not-semver", "abc", "1.2", ""] + for (const v of invalidVersions) { + expect(getAvailableVersion(v)).toBeUndefined() + } + }) + + test("dev mode rejects invalid semver (no false positives from corrupted KV)", () => { + if (Installation.VERSION !== "local") return + + // These are the values that the old custom isNewer would have shown + // because NaN fallback returned true — semver.valid rejects them + expect(getAvailableVersion("error")).toBeUndefined() + expect(getAvailableVersion("corrupted-data")).toBeUndefined() + expect(getAvailableVersion("local")).toBeUndefined() // matches VERSION anyway + }) +}) + +// ─── Race Condition / Edge Case Tests ───────────────────────────────────────── + +describe("upgrade indicator e2e: edge cases", () => { + test("rapid UpdateAvailable then Updated — indicator should be hidden", () => { + const kv = createMockKV() + + // Rapid succession: update available then immediately upgraded + simulateUpdateAvailableEvent(kv, "999.0.0") + simulateUpdatedEvent(kv) + + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("Updated without prior UpdateAvailable — no-op", () => { + const kv = createMockKV() + + // Updated fires but no UpdateAvailable was received + // KV key doesn't exist, so conditional check prevents write + simulateUpdatedEvent(kv) + + // KV should still not have the key (undefined !== Installation.VERSION) + // Actually: undefined !== VERSION is true, so it WILL write + // This is fine — setting to VERSION means getAvailableVersion returns undefined + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("same version available as current — indicator hidden", () => { + const kv = createMockKV() + + // Server sends UpdateAvailable with current version (edge case) + simulateUpdateAvailableEvent(kv, Installation.VERSION) + + // Should not show — kvValue === Installation.VERSION check catches this + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) +}) From cfa8c701b59311ac2cd6cffcea67a85e9836f856 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 13:01:53 -0700 Subject: [PATCH 5/6] fix: clarify upgrade indicator wording Change "available" to "update available" so users clearly understand a new version upgrade is pending, not just that a version exists. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx index be075096c7..0b35ced9a2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx @@ -15,7 +15,7 @@ export function UpgradeIndicator(props: { fallback?: JSX.Element }) { {version()} - available · altimate upgrade + update available · altimate upgrade )} From 46e9599986c34c17a32b5f95044e727e4875a45c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 13:03:01 -0700 Subject: [PATCH 6/6] fix: make upgrade indicator responsive for small screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On narrow terminals (<100 cols), hide "update available ·" text and show only the essential: ↑ 0.6.0 altimate upgrade Wide (100+): ↑ 0.6.0 update available · altimate upgrade Narrow (<100): ↑ 0.6.0 altimate upgrade Uses useTerminalDimensions() for reactive width detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/tui/component/upgrade-indicator.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx index 0b35ced9a2..5bcc86ee03 100644 --- a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx @@ -1,4 +1,5 @@ import { createMemo, Show, type JSX } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" import { useTheme } from "@tui/context/theme" import { useKV } from "../context/kv" import { UPGRADE_KV_KEY, getAvailableVersion } from "./upgrade-indicator-utils" @@ -6,8 +7,10 @@ import { UPGRADE_KV_KEY, getAvailableVersion } from "./upgrade-indicator-utils" export function UpgradeIndicator(props: { fallback?: JSX.Element }) { const { theme } = useTheme() const kv = useKV() + const dimensions = useTerminalDimensions() const latestVersion = createMemo(() => getAvailableVersion(kv.get(UPGRADE_KV_KEY))) + const isCompact = createMemo(() => dimensions().width < 100) return ( @@ -15,7 +18,10 @@ export function UpgradeIndicator(props: { fallback?: JSX.Element }) { {version()} - update available · altimate upgrade + + update available · + + altimate upgrade )}