diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index b1920203403..f9a1ecb01f0 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -915,10 +915,32 @@ test("lists slash commands in their built-in order", async ({ page }) => { "/mcp", "/model", "/connect", + "/reload", "/btw", ]) }) +test("/reload reloads the server's configuration once and reports it", async ({ page }) => { + const { editor } = await openSession(page, { name: "ComposerReload", provider: NO_PROVIDER }) + const reloaded = Promise.withResolvers() + let reloads = 0 + await page.route(/\/api\/location\/reload$/, async (route) => { + reloads++ + await reloaded.promise + await route.fulfill({ status: 204 }) + }) + + await editor.fill("/reload") + await expect(page.locator('[data-suggestion-id="config.reload"]')).toHaveAttribute("data-active", "") + await editor.press("Enter") + await expect(page.getByText("Reloading configuration…", { exact: true })).toBeVisible() + reloaded.resolve() + await expect(page.getByText("Configuration reloaded", { exact: true })).toBeVisible() + await expect(page.getByText("Reloading configuration…", { exact: true })).toBeHidden() + await expect(editor).toBeEmpty() + expect(reloads).toBe(1) +}) + const followUp = "Add follow-up, / for commands, @ for context…" for (const row of [ diff --git a/packages/app/src/composer/commands.tsx b/packages/app/src/composer/commands.tsx index 5396e75138e..b5223ec4c12 100644 --- a/packages/app/src/composer/commands.tsx +++ b/packages/app/src/composer/commands.tsx @@ -9,6 +9,10 @@ import { useSessionLayout } from "@/session/session-layout" import { createSessionOwnership } from "@/session/session-ownership" import { useWorkspaceLocation } from "@/workspaces/location" import { useData } from "@/runtime/server/current" +import { useServerSDK } from "@/runtime/server/client" +import { formatServerError } from "@/runtime/server/errors" +import { showToast } from "@/shell/notifications/toast" +import { toaster } from "@opencode/ui/toast" const withCategory = (category: string) => { return (option: Omit): CommandOption => ({ @@ -26,12 +30,14 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const workspace = useWorkspaceLocation() const { sessionKey, params } = useSessionLayout() const data = useData() + const sdk = useServerSDK() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model const modelCommand = withCategory(language.t("command.category.model")) const agentCommand = withCategory(language.t("command.category.agent")) const providerCommand = withCategory(language.t("command.category.provider")) const viewCommand = withCategory(language.t("command.category.view")) + const serverCommand = withCategory(language.t("command.category.server")) // Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run. const connectProvider = async () => { @@ -40,6 +46,28 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { void dialog.show(() => ) } + // Mirrors the TUI's `/reload`. The server rebuilds every loaded location and announces the changed agents, + // commands, models and skills, so the data layer refreshes them without restarting the app. + const reloadConfig = () => { + const pending = showToast({ + variant: "loading", + title: language.t("command.config.reload.pending"), + persistent: true, + }) + + void sdk.api.location + .reload() + .then(() => showToast({ variant: "success", title: language.t("command.config.reload.done") })) + .catch((error) => + showToast({ + variant: "error", + title: language.t("command.config.reload.failed"), + description: formatServerError(error, language.t), + }), + ) + .finally(() => toaster.dismiss(pending)) + } + const chooseModel = () => { const Picker = settings.general.newModelPicker() ? DialogSelectModelNext : DialogSelectModel sessionOwnership.capture().run(() => { @@ -50,10 +78,12 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const viewUsage = async () => { const { DialogUsage } = await import("@/usage/dialog") const current = model.current() + // Console refuses with 402 only when the workspace can't pay, which the dialog explains. const last = params.id ? data.session.message.list(params.id)?.findLast((message) => message.type === "assistant") : undefined + void dialog.show(() => ( { slash: "connect", onSelect: connectProvider, }), + serverCommand({ + id: "config.reload", + title: language.t("command.config.reload"), + description: language.t("command.config.reload.description"), + slash: "reload", + onSelect: reloadConfig, + }), agentCommand({ id: "agent.cycle", title: language.t("command.agent.cycle"), diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index b1e36e4271e..e520a335c7d 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -57,6 +57,11 @@ export const dict = { "command.provider.connect": "Connect an integration", "command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider", "command.settings.open": "Open settings", + "command.config.reload": "Reload configuration", + "command.config.reload.description": "Reload agents, commands, skills, MCP servers and plugins from your config", + "command.config.reload.pending": "Reloading configuration…", + "command.config.reload.done": "Configuration reloaded", + "command.config.reload.failed": "Couldn't reload configuration", "command.usage.view": "View usage", "command.usage.view.description": "See your OpenCode Go allowance and Console budgets", "command.session.location.cycle": "Cycle session location",