Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -842,13 +842,18 @@ 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",
message: `Altimate Code v${evt.properties.version} is available. Run 'altimate upgrade' to update manually.`,
duration: 10000,
})
})

sdk.event.on(Installation.Event.Updated.type, () => {
kv.set("update_available_version", Installation.VERSION)
})
// altimate_change end

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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" || !kvValue) return undefined
if (kvValue === Installation.VERSION) return undefined
if (!isNewer(kvValue, Installation.VERSION)) return undefined
return kvValue
}
28 changes: 28 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Show when={latestVersion()}>
{(version) => (
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>
{Installation.VERSION} → <span style={{ fg: theme.accent }}>{version()}</span>
</text>
<text fg={theme.textMuted}>·</text>
<text fg={theme.textMuted}>altimate upgrade</text>
</box>
)}
</Show>
)
}
7 changes: 6 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -152,7 +154,10 @@ export function Home() {
</box>
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
<UpgradeIndicator />
<Show when={!getAvailableVersion(kv.get(UPGRADE_KV_KEY))}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</Show>
</box>
</box>
</>
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -95,6 +96,7 @@ export function Footer() {
<text fg={theme.textMuted}>/status</text>
</Match>
</Switch>
<UpgradeIndicator />
</box>
</box>
)
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/cli/tui/upgrade-indicator.test.ts
Original file line number Diff line number Diff line change
@@ -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 is newer than current version", () => {
const result = getAvailableVersion("99.99.99")
expect(result).toBe("99.99.99")
})

test("returns undefined for empty string", () => {
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 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 {
expect(result).toBeUndefined()
}
})
})
})
140 changes: 140 additions & 0 deletions packages/opencode/test/cli/upgrade-notify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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<string, any> = {}
store[UPGRADE_KV_KEY] = "999.0.0"
expect(store[UPGRADE_KV_KEY]).toBe("999.0.0")
})

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(result).toBeUndefined()
}
})

test("indicator shown when stored version is newer than current", () => {
const store: Record<string, any> = {}
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<string, any> = {}
const result = getAvailableVersion(store[UPGRADE_KV_KEY])
expect(result).toBeUndefined()
})

test("KV value can be overwritten with newer version", () => {
const store: Record<string, any> = {}
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("999.0.0")
})

test("end-to-end: event → KV → indicator → reset on Updated", () => {
const store: Record<string, any> = {}

// Step 1: UpdateAvailable event stores version
store[UPGRADE_KV_KEY] = "999.0.0"
expect(getAvailableVersion(store[UPGRADE_KV_KEY])).toBe("999.0.0")

// Step 2: Updated event sets KV to current version (F1 fix)
store[UPGRADE_KV_KEY] = Installation.VERSION
expect(getAvailableVersion(store[UPGRADE_KV_KEY])).toBeUndefined()
})
})
Loading