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
1 change: 0 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "opencode",
Expand Down
78 changes: 76 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createMemo, createResource, For, Show, Switch, Match, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
Expand All @@ -11,6 +11,7 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
import { useSDK } from "../../context/sdk"

export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
Expand All @@ -20,7 +21,7 @@ export function Sidebar(props: { sessionID: string }) {
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])

const [expanded, setExpanded] = createStore({
const [expanded, setExpanded] = createStore<Record<string, boolean>>({
mcp: true,
diff: true,
todo: true,
Expand Down Expand Up @@ -62,6 +63,26 @@ export function Sidebar(props: { sessionID: string }) {

const directory = useDirectory()
const kv = useKV()
const sdk = useSDK()

const [pluginPanels, { refetch: refetchPluginPanels }] = createResource(
() => props.sessionID,
async () => {
try {
const result = await sdk.client.plugin.sidebar()
return result.data ?? []
} catch (e) {
console.warn("Failed to fetch plugin sidebar panels:", e)
return []
}
},
)

// Poll plugin panels every 5 seconds for dynamic updates
const pluginPollInterval = setInterval(() => {
refetchPluginPanels()
}, 5000)
onCleanup(() => clearInterval(pluginPollInterval))

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 @@ -263,6 +284,59 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
</box>
</Show>
<For each={pluginPanels() ?? []}>
{(panel) => {
const panelKey = `plugin_${panel.id}`
const isExpanded = () => expanded[panelKey] ?? true
return (
<Show when={panel.items.length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => panel.items.length > 2 && setExpanded(panelKey, !isExpanded())}
>
<Show when={panel.items.length > 2}>
<text fg={theme.text}>{isExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>{panel.title}</b>
<Show when={!isExpanded()}>
<span style={{ fg: theme.textMuted }}> ({panel.items.length} items)</span>
</Show>
</text>
</box>
<Show when={panel.items.length <= 2 || isExpanded()}>
<For each={panel.items}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted}>{item.label}</text>
<Show when={item.value}>
<text
fg={
item.status === "success"
? theme.success
: item.status === "warning"
? theme.warning
: item.status === "error"
? theme.error
: item.status === "info"
? theme.info
: theme.textMuted
}
>
{item.value}
</text>
</Show>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}}
</For>
</box>
</scrollbox>

Expand Down
51 changes: 39 additions & 12 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export namespace Plugin {
fetch: async (...args) => Server.App().fetch(...args),
})
const config = await Config.get()
const hooks = []
const hooks: Hooks[] = []
const input: PluginInput = {
client,
project: Instance.project,
Expand All @@ -33,16 +33,20 @@ export namespace Plugin {
}
for (let plugin of plugins) {
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version)
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn(input)
hooks.push(init)
try {
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version)
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn(input)
hooks.push(init)
}
} catch (e) {
log.error("failed to load plugin", { path: plugin, error: e })
}
}

Expand All @@ -53,7 +57,7 @@ export namespace Plugin {
})

export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "sidebar">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
Expand All @@ -73,6 +77,29 @@ export namespace Plugin {
return state().then((x) => x.hooks)
}

export async function getSidebarPanels() {
const { hooks } = await state()
const panels: Array<{
id: string
title: string
items: Array<{ label: string; value?: string; status?: "success" | "warning" | "error" | "info" }>
}> = []
for (const hook of hooks) {
const sidebar = hook.sidebar
if (!sidebar) continue
try {
const resolved = typeof sidebar === "function" ? sidebar() : sidebar
for (const panel of resolved) {
const items = typeof panel.items === "function" ? panel.items() : panel.items
panels.push({ id: panel.id, title: panel.title, items })
}
} catch (e) {
log.warn("sidebar panel failed", { error: e })
}
}
return panels
}

export async function init() {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Auth } from "../auth"
import { Command } from "../command"
import { ProviderAuth } from "../provider/auth"
import { Global } from "../global"
import { Plugin } from "../plugin"
import { ProjectRoute } from "./project"
import { ToolRegistry } from "../tool/registry"
import { zodToJsonSchema } from "zod-to-json-schema"
Expand Down Expand Up @@ -2569,6 +2570,41 @@ export namespace Server {
return c.json(true)
},
)
.get(
"/plugin/sidebar",
describeRoute({
summary: "Get plugin sidebar panels",
description: "Get sidebar panels registered by plugins.",
operationId: "plugin.sidebar",
responses: {
200: {
description: "Plugin sidebar panels",
content: {
"application/json": {
schema: resolver(
z.array(
z.object({
id: z.string(),
title: z.string(),
items: z.array(
z.object({
label: z.string(),
value: z.string().optional(),
status: z.enum(["success", "warning", "error", "info"]).optional(),
}),
),
}),
),
),
},
},
},
},
}),
async (c) => {
return c.json(await Plugin.getSidebarPanels())
},
)
.get(
"/event",
describeRoute({
Expand Down
29 changes: 29 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ export type PluginInput = {
$: BunShell
}

/**
* Sidebar panel item displayed in a plugin's sidebar section
*/
export type SidebarPanelItem = {
/** Label shown on the left */
label: string
/** Optional value shown on the right */
value?: string
/** Optional status indicator for styling */
status?: "success" | "warning" | "error" | "info"
}

/**
* A sidebar panel registered by a plugin
*/
export type SidebarPanel = {
/** Unique identifier for this panel */
id: string
/** Title displayed in the sidebar section header */
title: string
/** Items to display in the panel - can be static array or getter for dynamic content */
items: SidebarPanelItem[] | (() => SidebarPanelItem[])
}

export type Plugin = (input: PluginInput) => Promise<Hooks>

export type AuthHook = {
Expand Down Expand Up @@ -206,4 +230,9 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Register custom sidebar panels. Can be a static array or a getter function
* for dynamic content that updates on each render.
*/
sidebar?: SidebarPanel[] | (() => SidebarPanel[])
}
24 changes: 24 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
PermissionListResponses,
PermissionRespondErrors,
PermissionRespondResponses,
PluginSidebarResponses,
ProjectCurrentResponses,
ProjectListResponses,
ProjectUpdateErrors,
Expand Down Expand Up @@ -2654,6 +2655,27 @@ export class Tui extends HeyApiClient {
control = new Control({ client: this.client })
}

export class Plugin extends HeyApiClient {
/**
* Get plugin sidebar panels
*
* Get sidebar panels registered by plugins.
*/
public sidebar<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<PluginSidebarResponses, unknown, ThrowOnError>({
url: "/plugin/sidebar",
...options,
...params,
})
}
}

export class Event extends HeyApiClient {
/**
* Subscribe to events
Expand Down Expand Up @@ -2725,5 +2747,7 @@ export class OpencodeClient extends HeyApiClient {

auth = new Auth({ client: this.client })

plugin = new Plugin({ client: this.client })

event = new Event({ client: this.client })
}
26 changes: 26 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4340,6 +4340,32 @@ export type AuthSetResponses = {

export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]

export type PluginSidebarData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/plugin/sidebar"
}

export type PluginSidebarResponses = {
/**
* Plugin sidebar panels
*/
200: Array<{
id: string
title: string
items: Array<{
label: string
value?: string
status?: "success" | "warning" | "error" | "info"
}>
}>
}

export type PluginSidebarResponse = PluginSidebarResponses[keyof PluginSidebarResponses]

export type EventSubscribeData = {
body?: never
path?: never
Expand Down
Loading