diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0ae0986e420..ec5beb94a4e 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -272,6 +272,7 @@ export const Definitions = { "dialog.mcp.toggle": keybind("space", "Toggle MCP server"), "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), "dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"), + "dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"), "terminal.suspend": keybind("ctrl+z", "Suspend terminal"), "terminal.title.toggle": keybind("none", "Toggle terminal title"), diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index b9d6542bf7b..19b2f8b1b68 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -27,10 +27,11 @@ export function PluginsDialog(props: { }) { const dialog = useDialog() const [locked, setLocked] = createSignal(false) + const [checking, setChecking] = createSignal(false) const [focused, setFocused] = createSignal() const [detail, setDetail] = createSignal() const [showInternal, setShowInternal] = createSignal(false) - const [server, { refetch }] = createResource( + const [server, { refetch, mutate }] = createResource( () => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())), (location) => props.context.client.plugin.list({ location }).then((result) => result.data), ) @@ -145,6 +146,34 @@ export function PluginsDialog(props: { }) }) } + // The server only re-checks package sources on startup and then caches the + // result for a day, so a merge pushed after launch stays invisible until the + // user asks. The check response carries fresh `outdated` flags for the whole + // inventory; apply it directly instead of waiting for a `plugin.updated` + // event, which only fires when a flag actually changes. + const check = () => { + if (checking()) return + setChecking(true) + props.context.client.plugin + .check({ location: props.context.location ?? props.context.data.location.default() }) + .then((result) => { + mutate(result.data) + const count = result.data.filter( + (plugin) => plugin.source.type === "package" && plugin.source.outdated === true, + ).length + props.context.ui.toast.show({ + variant: count ? "info" : "success", + message: count ? `${count} plugin update${count === 1 ? "" : "s"} available` : "All plugins are up to date", + }) + }) + .catch((cause) => { + props.context.ui.toast.show({ + variant: "error", + message: cause instanceof Error ? cause.message : String(cause), + }) + }) + .finally(() => setChecking(false)) + } return ( @@ -191,6 +220,16 @@ export function PluginsDialog(props: { hidden: !updatable(focusedEntry()), onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)), }, + { + title: checking() ? "checking" : "check", + command: "dialog.plugins.check", + selection: "none", + hidden: !entries().some( + (entry) => entry.runtime === "server" && entry.plugin.source.type === "package", + ), + disabled: checking(), + onTrigger: check, + }, ]} footer={ diff --git a/packages/tui/test/cli/tui/plugins-dialog.test.tsx b/packages/tui/test/cli/tui/plugins-dialog.test.tsx new file mode 100644 index 00000000000..c444f35f4c0 --- /dev/null +++ b/packages/tui/test/cli/tui/plugins-dialog.test.tsx @@ -0,0 +1,160 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onCleanup } from "solid-js" +import type { PluginInfo } from "@opencode-ai/client" +import type { Context, ToastOptions } from "@opencode-ai/plugin/tui/context" +import { ConfigProvider } from "../../../src/config" +import { Keymap } from "../../../src/context/keymap" +import { ThemeProvider, useThemes } from "../../../src/context/theme" +// The plugin context registers every builtin, and the plugins dialog imports +// the context back, so the context must load first exactly as it does in the app. +import type { usePlugin } from "../../../src/plugin/context" +import "../../../src/plugin/context" +import { PluginsDialog } from "../../../src/feature-plugins/system/plugins" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { emptyThemeSource, tmpdir } from "../../fixture/fixture" +import { createApi, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const target = "git+ssh://git@github.com/example/team-plugins.git" + +function packagePlugin(outdated: boolean): PluginInfo { + return { + id: "team.plugins", + source: { type: "package", target, version: "dadba13", ...(outdated ? { outdated: true as const } : {}) }, + features: { server: true }, + state: { status: "active" }, + } +} + +async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) { + const state = path.join(root, "state") + await mkdir(state, { recursive: true }) + const requests: { path: string; body: unknown }[] = [] + const toasts: ToastOptions[] = [] + const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } } + const transport = createFetch(async (url, request) => { + if (url.pathname === "/api/plugin") return json({ location, data: inventory.list }) + if (url.pathname === "/api/plugin/check") { + requests.push({ path: url.pathname, body: await request.json() }) + return json({ location, data: inventory.check }) + } + if (url.pathname === "/api/plugin/update") { + requests.push({ path: url.pathname, body: await request.json() }) + return new Response(null, { status: 204 }) + } + }) + + function Harness() { + function Content() { + onCleanup(Keymap.use().mode.push("modal")) + const theme = useThemes().currentTokens() + const context = { + client: createApi(transport.fetch), + data: { location: { default: () => ({ directory: root }) }, on: () => () => {} }, + get theme() { + return theme + }, + ui: { + toast: { show: (toast: ToastOptions) => toasts.push(toast) }, + format: { path: (value: string) => value }, + }, + } as unknown as Context + const plugins = { + registered: () => [], + list: () => [], + activate: async () => true, + deactivate: async () => true, + } as unknown as ReturnType + return + } + + return ( + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 80, height: 20, kittyKeyboard: true }) + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("Plugins")) + return { app, requests, toasts } +} + +test("checking for updates refreshes the inventory and reveals the update action", async () => { + await using tmp = await tmpdir() + const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] }) + + try { + const initial = await fixture.app.waitForFrame((frame) => frame.includes("dadba13")) + expect(initial).toContain("check ctrl+r") + expect(initial).not.toContain("update available") + expect(initial).not.toContain("ctrl+u") + + fixture.app.mockInput.pressKey("r", { ctrl: true }) + const checked = await fixture.app.waitForFrame((frame) => frame.includes("update available")) + expect(checked).toContain("ctrl+u") + expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }]) + expect(fixture.toasts).toEqual([{ variant: "info", message: "1 plugin update available" }]) + + fixture.app.mockInput.pressKey("u", { ctrl: true }) + await fixture.app.waitFor(() => fixture.requests.length === 2) + expect(fixture.requests[1]).toEqual({ path: "/api/plugin/update", body: { targets: [target] } }) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("checking for updates reports an up-to-date inventory", async () => { + await using tmp = await tmpdir() + const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(false)] }) + + try { + await fixture.app.waitForFrame((frame) => frame.includes("dadba13")) + fixture.app.mockInput.pressKey("r", { ctrl: true }) + await fixture.app.waitFor(() => fixture.toasts.length === 1) + + expect(fixture.toasts).toEqual([{ variant: "success", message: "All plugins are up to date" }]) + expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("the check action stays hidden without package plugins", async () => { + await using tmp = await tmpdir() + const local: PluginInfo = { + id: "local.plugin", + source: { type: "local", path: path.join(tmp.path, "plugin.ts") }, + features: { server: true }, + state: { status: "active" }, + } + const fixture = await renderPlugins(tmp.path, { list: [local], check: [] }) + + try { + const frame = await fixture.app.waitForFrame((frame) => frame.includes("local.plugin")) + expect(frame).not.toContain("ctrl+r") + + fixture.app.mockInput.pressKey("r", { ctrl: true }) + await fixture.app.flush() + expect(fixture.requests).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) diff --git a/packages/www/src/docs/content/cli/keybinds.mdx b/packages/www/src/docs/content/cli/keybinds.mdx index 77b239426cd..d51fadeb0c5 100644 --- a/packages/www/src/docs/content/cli/keybinds.mdx +++ b/packages/www/src/docs/content/cli/keybinds.mdx @@ -345,6 +345,7 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an | `dialog.mcp.toggle` | `space` | Toggle MCP server | | `dialog.plugins.install` | `shift+i` | Install plugin from plugin dialog | | `dialog.plugins.update` | `ctrl+u` | Update plugin from plugin dialog | +| `dialog.plugins.check` | `ctrl+r` | Check for plugin updates | ## Terminal And Plugins