-
Notifications
You must be signed in to change notification settings - Fork 41
feat: show update-available indicator in TUI footer #175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e97e43c
feat: show update-available indicator in TUI footer
anandgupta42 1b86aff
fix: address bot review findings for upgrade indicator
anandgupta42 52eaacb
fix: address code review findings for upgrade indicator
anandgupta42 32fce2f
test: add e2e tests for upgrade indicator lifecycle
anandgupta42 cfa8c70
fix: clarify upgrade indicator wording
anandgupta42 46e9599
fix: make upgrade indicator responsive for small screens
anandgupta42 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
28
packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.