Skip to content
Closed
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
19 changes: 19 additions & 0 deletions packages/opencode/src/plugin/tui/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,24 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
},
}

const selection: TuiPluginApi["selection"] = {
on(handler) {
return scope.track(api.selection.on(handler))
},
onCancel(handler) {
return scope.track(api.selection.onCancel(handler))
},
arm() {
api.selection.arm()
},
cancel() {
api.selection.cancel()
},
armed() {
return api.selection.armed()
},
}

const keymap = createScopedKeymap(api.keymap, scope)

let count = 0
Expand Down Expand Up @@ -628,6 +646,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
},
event,
renderer: api.renderer,
selection,
slots,
plugins: {
list() {
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/test/fixture/tui-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
},
},
renderer,
selection: {
on: () => () => {},
onCancel: () => () => {},
arm: () => {},
cancel: () => {},
armed: () => false,
},
slots: {
register: () => "fixture-slot",
},
Expand Down Expand Up @@ -325,6 +332,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
part: opts.state?.part ?? (() => []),
lsp: opts.state?.lsp ?? (() => []),
mcp: opts.state?.mcp ?? (() => []),
skill: {
list: () => [],
refresh: async () => {},
},
},
theme: {
get current() {
Expand Down
23 changes: 23 additions & 0 deletions packages/plugin/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SkillV2Info,
TextPart,
Config as SdkConfig,
} from "@opencode-ai/sdk/v2"
Expand Down Expand Up @@ -396,6 +397,10 @@ export type TuiState = {
part: (messageID: string) => ReadonlyArray<Part>
lsp: () => ReadonlyArray<TuiSidebarLspItem>
mcp: () => ReadonlyArray<TuiSidebarMcpItem>
skill: {
list: (sessionID: string) => ReadonlyArray<TuiSidebarSkillItem>
refresh: (sessionID: string) => Promise<void>
}
}

type TuiBindingLookupView = {
Expand Down Expand Up @@ -452,6 +457,8 @@ export type TuiSidebarFileItem = {
deletions: number
}

export type TuiSidebarSkillItem = Pick<SkillV2Info, "name" | "description">

export type TuiHostSlotMap = {
app: {}
app_bottom: {}
Expand Down Expand Up @@ -516,6 +523,21 @@ export type TuiSlots = {
}
}

export type TuiSelectionEvent = {
text: string
x: number
y: number
renderables: readonly Renderable[]
}

export type TuiSelection = {
on: (handler: (selection: TuiSelectionEvent) => void) => () => void
onCancel: (handler: () => void) => () => void
arm: () => void
cancel: () => void
armed: () => boolean
}

export type TuiEventBus = {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
}
Expand Down Expand Up @@ -614,6 +636,7 @@ export type TuiPluginApi = {
client: OpencodeClient
event: TuiEventBus
renderer: CliRenderer
selection: TuiSelection
slots: TuiSlots
plugins: {
list: () => ReadonlyArray<TuiPluginStatus>
Expand Down
62 changes: 56 additions & 6 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { PermissionProvider } from "./context/permission"
Expand Down Expand Up @@ -86,6 +86,7 @@ import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import type { TuiSelectionEvent } from "@opencode-ai/plugin/tui"

registerOpencodeSpinner()

Expand Down Expand Up @@ -378,12 +379,43 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const data = useData()
const project = useProject()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
const clipboard = useClipboard()
const selectionListeners = new Set<(selection: TuiSelectionEvent) => void>()
const selectionCancelListeners = new Set<() => void>()
let pluginSelectionArmed = false
let pluginSelectionStarted = false
const selection = {
on(handler: (selection: TuiSelectionEvent) => void) {
selectionListeners.add(handler)
return () => selectionListeners.delete(handler)
},
onCancel(handler: () => void) {
selectionCancelListeners.add(handler)
return () => selectionCancelListeners.delete(handler)
},
publish(value: TuiSelectionEvent) {
for (const handler of selectionListeners) handler(value)
},
arm() {
pluginSelectionArmed = true
renderer.clearSelection()
},
cancel() {
pluginSelectionArmed = false
pluginSelectionStarted = false
renderer.clearSelection()
for (const handler of selectionCancelListeners) handler()
},
armed() {
return pluginSelectionArmed
},
}

const api = createTuiApi(
createTuiApiAdapters({
Expand All @@ -397,9 +429,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event,
sdk,
sync,
data,
theme: themeState,
toast,
renderer,
selection,
attention,
Slot: pluginRuntime.Slot,
}),
Expand All @@ -423,6 +457,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const offSelectionKeys = keymap.intercept(
"key",
({ event }) => {
if (pluginSelectionArmed && event.name === "escape") {
selection.cancel()
event.preventDefault()
event.stopPropagation()
return
}
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
Selection.handleSelectionKey(renderer, toast, event, clipboard)
},
Expand Down Expand Up @@ -1091,18 +1131,28 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
flexDirection="column"
backgroundColor={theme.background}
onMouseDown={(evt) => {
pluginSelectionStarted = Selection.startPluginSelection(renderer, evt, pluginSelectionArmed)
if (pluginSelectionStarted) return
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return

if (!Selection.copy(renderer, toast, clipboard)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
? () => Selection.copy(renderer, toast, clipboard)
: undefined
}
onMouseUp={(evt) => {
const handledPluginSelection = Selection.capturePluginSelection(
renderer,
evt,
selection.publish,
pluginSelectionStarted,
)
pluginSelectionStarted = false
if (handledPluginSelection) {
return
}
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) Selection.copy(renderer, toast, clipboard)
}}
>
<Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw />
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/feature-plugins/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SidebarFiles from "./sidebar/files"
import SidebarFooter from "./sidebar/footer"
import SidebarLsp from "./sidebar/lsp"
import SidebarMcp from "./sidebar/mcp"
import SidebarSkills from "./sidebar/skills"
import SidebarTodo from "./sidebar/todo"
import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications"
Expand All @@ -24,6 +25,7 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
HomeTips,
SidebarContext,
SidebarMcp,
SidebarSkills,
SidebarLsp,
SidebarTodo,
SidebarFiles,
Expand Down
42 changes: 42 additions & 0 deletions packages/tui/src/feature-plugins/sidebar/skill-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type SkillUsage = Record<string, { count: number; lastUsed: number }>

export function sanitizeSkillUsage(value: unknown, now: number): SkillUsage {
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
return Object.fromEntries(
Object.entries(value).flatMap(([name, item]) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return []
const count = Reflect.get(item, "count")
const lastUsed = Reflect.get(item, "lastUsed")
if (!Number.isSafeInteger(count) || count < 0) return []
if (!Number.isFinite(lastUsed) || lastUsed < 0) return []
return [[name, { count, lastUsed: Math.min(lastUsed, now) }]]
}),
)
}

export function incrementSkillUsage(value: SkillUsage, name: string, now: number): SkillUsage {
const current = value[name]
return {
...value,
[name]: {
count: (current?.count ?? 0) + 1,
lastUsed: now,
},
}
}

export function rankSkills(names: readonly string[], value: SkillUsage, now: number, limit = 10) {
return [...new Set(names)]
.map((name) => {
const usage = value[name]
const age = usage ? Math.max(0, now - usage.lastUsed) / 86_400_000 : 0
return {
name,
count: usage?.count ?? 0,
lastUsed: usage?.lastUsed ?? 0,
score: usage ? usage.count / (1 + age) : 0,
}
})
.sort((a, b) => b.score - a.score || b.lastUsed - a.lastUsed || a.name.localeCompare(b.name))
.slice(0, limit)
}
101 changes: 101 additions & 0 deletions packages/tui/src/feature-plugins/sidebar/skills.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { incrementSkillUsage, rankSkills, sanitizeSkillUsage, type SkillUsage } from "./skill-usage"

const id = "internal:sidebar-skills"
const key = "sidebar_skill_usage"

function View(props: { api: TuiPluginApi; sessionID: string; usage: () => SkillUsage }) {
const [open, setOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
const theme = () => props.api.theme.current
const skills = createMemo(() => props.api.state.skill.list(props.sessionID))
const ranked = createMemo(() => rankSkills(skills().map((item) => item.name), props.usage(), now()))

onMount(() => {
void props.api.state.skill.refresh(props.sessionID).catch(() => {})
const timer = setInterval(() => setNow(Date.now()), 3_600_000)
onCleanup(() => clearInterval(timer))
})

return (
<box>
<box flexDirection="row" gap={1} onMouseDown={() => skills().length > 0 && setOpen((value) => !value)}>
<Show when={skills().length > 0}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>Skills</b> <span style={{ fg: theme().textMuted }}>({skills().length})</span>
</text>
</box>
<Show when={open()}>
<Show when={skills().length === 0}>
<text fg={theme().textMuted}>No skills registered</text>
</Show>
<For each={ranked()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme().textMuted}>
</text>
<text fg={theme().text} wrapMode="word">
{item.name}
<Show when={item.count > 0}>
<span style={{ fg: theme().textMuted }}> {item.count}</span>
</Show>
</text>
</box>
)}
</For>
</Show>
</box>
)
}

const tui: TuiPlugin = async (api) => {
const [usage, setUsage] = createSignal(sanitizeSkillUsage(api.kv.get(key), Date.now()))
const completed = new Set<string>()

const record = (name: unknown, eventID: string) => {
if (typeof name !== "string" || name.length === 0 || completed.has(eventID)) return
completed.add(eventID)
const next = incrementSkillUsage(usage(), name, Date.now())
setUsage(next)
api.kv.set(key, next)
}

api.lifecycle.onDispose(
api.event.on("message.part.updated", (event) => {
const part = event.properties.part
if (part.type !== "tool" || part.tool !== "skill" || part.state.status !== "completed") return
record(Reflect.get(part.state.input, "name"), part.id)
}),
)

api.lifecycle.onDispose(
api.event.on("command.executed", (event) => {
const session = api.state.session.get(event.properties.sessionID)
if (!session) return
const skills = api.state.skill.list(event.properties.sessionID)
if (!skills.some((item) => item.name === event.properties.name)) return
record(event.properties.name, event.id)
}),
)

api.slots.register({
order: 250,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} sessionID={props.session_id} usage={usage} />
},
},
})
}

const plugin: BuiltinTuiPlugin = {
id,
tui,
}

export default plugin
Loading
Loading