diff --git a/packages/app/e2e/status/status-popover.spec.ts b/packages/app/e2e/status/status-popover.spec.ts index d53578a4910..362460f29aa 100644 --- a/packages/app/e2e/status/status-popover.spec.ts +++ b/packages/app/e2e/status/status-popover.spec.ts @@ -9,6 +9,7 @@ test("status popover opens and shows tabs", async ({ page, gotoSession }) => { await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible() await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible() await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible() + await expect(popoverBody.getByRole("tab", { name: /skills/i })).toBeVisible() await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible() await page.keyboard.press("Escape") @@ -72,6 +73,21 @@ test("status popover can switch to plugins tab", async ({ page, gotoSession }) = await expect(pluginsContent).toBeVisible() }) +test("status popover can switch to skills tab", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + + const skillsTab = popoverBody.getByRole("tab", { name: /skills/i }) + await skillsTab.click() + + const ariaSelected = await skillsTab.getAttribute("aria-selected") + expect(ariaSelected).toBe("true") + + const skillsContent = popoverBody.locator('[role="tabpanel"]:visible').first() + await expect(skillsContent).toBeVisible() +}) + test("status popover closes on escape", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index b441d1c5efc..b5b44f8bf6d 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -5,7 +5,7 @@ import { Popover } from "@opencode-ai/ui/popover" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" import { showToast } from "@opencode-ai/ui/toast" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -16,6 +16,7 @@ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/serve import { useSync } from "@/context/sync" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { DialogSelectServer } from "./dialog-select-server" +import { type Part } from "@opencode-ai/sdk/v2/client" const pollMs = 10_000 @@ -31,6 +32,18 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => { ) } +const skillName = (part: Part) => { + if (part.type !== "tool" || part.tool !== "skill") return + if (typeof part.state !== "object" || !part.state) return + if (!("input" in part.state)) return + if (typeof part.state.input !== "object" || !part.state.input) return + const name = "name" in part.state.input ? part.state.input.name : undefined + if (typeof name !== "string") return + const value = name.trim() + if (!value) return + return value +} + const listServersByHealth = ( list: ServerConnection.Any[], active: ServerConnection.Key | undefined, @@ -167,6 +180,7 @@ export function StatusPopover() { const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() + const params = useParams() const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { @@ -185,6 +199,19 @@ export function StatusPopover() { const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const lspItems = createMemo(() => sync.data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) + const skills = createMemo(() => { + const id = params.id + if (!id) return [] + const seen = new Set() + for (const message of sync.data.message[id] ?? []) { + for (const part of sync.data.part[message.id] ?? []) { + const name = skillName(part) + if (name) seen.add(name) + } + } + return [...seen].sort((a, b) => a.localeCompare(b)) + }) + const skillCount = createMemo(() => skills().length) const plugins = createMemo(() => sync.data.config.plugin ?? []) const pluginCount = createMemo(() => plugins().length) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) @@ -248,6 +275,10 @@ export function StatusPopover() { {lspCount() > 0 ? `${lspCount()} ` : ""} {language.t("status.popover.tab.lsp")} + + {skillCount() > 0 ? `${skillCount()} ` : ""} + {language.t("status.popover.tab.skills")} + {pluginCount() > 0 ? `${pluginCount()} ` : ""} {language.t("status.popover.tab.plugins")} @@ -411,6 +442,30 @@ export function StatusPopover() { + + +
+
+ 0} + fallback={ +
+ {language.t("dialog.skills.empty")} +
+ } + > + + {(item) => ( +
+
+ {item} +
+ )} + + +
+
+
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 26cf433e0e3..e997a81f49a 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -270,6 +270,7 @@ export const dict = { "dialog.mcp.description": "{{enabled}} of {{total}} habilitados", "dialog.mcp.empty": "Nenhum MCP configurado", "dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo", + "dialog.skills.empty": "Habilidades do usuário ou do projeto", "dialog.plugins.empty": "Plugins configurados em opencode.json", "mcp.status.connected": "conectado", "mcp.status.failed": "falhou", @@ -475,6 +476,7 @@ export const dict = { "status.popover.tab.servers": "Servidores", "status.popover.tab.mcp": "MCP", "status.popover.tab.lsp": "LSP", + "status.popover.tab.skills": "Habilidades", "status.popover.tab.plugins": "Plugins", "status.popover.action.manageServers": "Gerenciar servidores", "session.share.popover.title": "Publicar na web", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df..51cbecb2f13 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -294,6 +294,7 @@ export const dict = { "dialog.mcp.empty": "No MCPs configured", "dialog.lsp.empty": "LSPs auto-detected from file types", + "dialog.skills.empty": "User or project skills", "dialog.plugins.empty": "Plugins configured in opencode.json", "mcp.status.connected": "connected", @@ -547,6 +548,7 @@ export const dict = { "status.popover.tab.servers": "Servers", "status.popover.tab.mcp": "MCP", "status.popover.tab.lsp": "LSP", + "status.popover.tab.skills": "Skills", "status.popover.tab.plugins": "Plugins", "status.popover.action.manageServers": "Manage servers", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 2665a808508..35b626ec9d0 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -292,6 +292,7 @@ export const dict = { "dialog.mcp.empty": "No hay MCPs configurados", "dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo", + "dialog.skills.empty": "Habilidades del usuario o del proyecto", "dialog.plugins.empty": "Plugins configurados en opencode.json", "mcp.status.connected": "conectado", @@ -534,6 +535,7 @@ export const dict = { "status.popover.tab.servers": "Servidores", "status.popover.tab.mcp": "MCP", "status.popover.tab.lsp": "LSP", + "status.popover.tab.skills": "Habilidades", "status.popover.tab.plugins": "Plugins", "status.popover.action.manageServers": "Administrar servidores", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 1e67db19333..e46c37e3f10 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -270,6 +270,7 @@ export const dict = { "dialog.mcp.description": "{{enabled}} sur {{total}} activés", "dialog.mcp.empty": "Aucun MCP configuré", "dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier", + "dialog.skills.empty": "Compétences utilisateur ou projet", "dialog.plugins.empty": "Plugins configurés dans opencode.json", "mcp.status.connected": "connecté", "mcp.status.failed": "échoué", @@ -479,6 +480,7 @@ export const dict = { "status.popover.tab.servers": "Serveurs", "status.popover.tab.mcp": "MCP", "status.popover.tab.lsp": "LSP", + "status.popover.tab.skills": "Compétences", "status.popover.tab.plugins": "Plugins", "status.popover.action.manageServers": "Gérer les serveurs", "session.share.popover.title": "Publier sur le web", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 97c910a47d4..50c0c66b8d5 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -657,6 +657,15 @@ function App() { dialog.clear() }, }, + { + title: kv.get("show_skills_section", true) ? "Hide skills section" : "Show skills section", + value: "app.toggle.skills", + category: "System", + onSelect: (dialog) => { + kv.set("show_skills_section", !kv.get("show_skills_section", true)) + dialog.clear() + }, + }, ]) createEffect(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080..86df3e333f4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -20,11 +20,30 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + const usedSkills = createMemo(() => { + const seen = new Set() + for (const msg of messages()) { + for (const part of sync.data.part[msg.id] ?? []) { + if (part.type !== "tool" || part.tool !== "skill") continue + if (typeof part.state !== "object" || !part.state) continue + if (!("input" in part.state)) continue + if (typeof part.state.input !== "object" || !part.state.input) continue + const name = "name" in part.state.input ? part.state.input.name : undefined + if (typeof name !== "string") continue + const value = name.trim() + if (!value) continue + seen.add(value) + } + } + return [...seen].sort((a, b) => a.localeCompare(b)) + }) + const [expanded, setExpanded] = createStore({ mcp: true, diff: true, todo: true, lsp: true, + skill: true, }) // Sort MCP servers alphabetically for consistent display order @@ -62,6 +81,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const directory = useDirectory() const kv = useKV() + const showSkills = createMemo(() => kv.get("show_skills_section", true)) const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), @@ -210,6 +230,37 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { + + + usedSkills().length > 2 && setExpanded("skill", !expanded.skill)} + > + 2}> + {expanded.skill ? "▼" : "▶"} + + + Skills + + + + + Skills will appear when invoked + + + {(name) => ( + + + • + + {name} + + )} + + + + 0 && todo().some((t) => t.status !== "completed")}>