Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions packages/app/e2e/status/status-popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down
57 changes: 56 additions & 1 deletion packages/app/src/components/status-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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<string>()
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"))
Expand Down Expand Up @@ -248,6 +275,10 @@ export function StatusPopover() {
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Tabs.Trigger value="skills" data-slot="tab" class="text-12-regular">
{skillCount() > 0 ? `${skillCount()} ` : ""}
{language.t("status.popover.tab.skills")}
</Tabs.Trigger>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
Expand Down Expand Up @@ -411,6 +442,30 @@ export function StatusPopover() {
</div>
</div>
</Tabs.Content>

<Tabs.Content value="skills">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={skills().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{language.t("dialog.skills.empty")}
</div>
}
>
<For each={skills()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{item}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
</Popover>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",

Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",

Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -210,6 +230,37 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</For>
</Show>
</box>
<Show when={showSkills()}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => usedSkills().length > 2 && setExpanded("skill", !expanded.skill)}
>
<Show when={usedSkills().length > 2}>
<text fg={theme.text}>{expanded.skill ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Skills</b>
</text>
</box>
<Show when={usedSkills().length <= 2 || expanded.skill}>
<Show when={usedSkills().length === 0}>
<text fg={theme.textMuted}>Skills will appear when invoked</text>
</Show>
<For each={usedSkills()}>
{(name) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: theme.success }}>
</text>
<text fg={theme.textMuted}>{name}</text>
</box>
)}
</For>
</Show>
</box>
</Show>
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
<box>
<box
Expand Down
Loading