diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 2a88ad3d5934..a9e28c046ae7 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -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 @@ -628,6 +646,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop }, event, renderer: api.renderer, + selection, slots, plugins: { list() { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 5d93139f002b..3da9148da39f 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -218,6 +218,13 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, }, renderer, + selection: { + on: () => () => {}, + onCancel: () => () => {}, + arm: () => {}, + cancel: () => {}, + armed: () => false, + }, slots: { register: () => "fixture-slot", }, @@ -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() { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 67812169475b..97bdb91b9fb8 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -13,6 +13,7 @@ import type { QuestionRequest, Session, SessionStatus, + SkillV2Info, TextPart, Config as SdkConfig, } from "@opencode-ai/sdk/v2" @@ -396,6 +397,10 @@ export type TuiState = { part: (messageID: string) => ReadonlyArray lsp: () => ReadonlyArray mcp: () => ReadonlyArray + skill: { + list: (sessionID: string) => ReadonlyArray + refresh: (sessionID: string) => Promise + } } type TuiBindingLookupView = { @@ -452,6 +457,8 @@ export type TuiSidebarFileItem = { deletions: number } +export type TuiSidebarSkillItem = Pick + export type TuiHostSlotMap = { app: {} app_bottom: {} @@ -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: Type, handler: (event: Extract) => void) => () => void } @@ -614,6 +636,7 @@ export type TuiPluginApi = { client: OpencodeClient event: TuiEventBus renderer: CliRenderer + selection: TuiSelection slots: TuiSlots plugins: { list: () => ReadonlyArray diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..fc8c5f0492b3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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" @@ -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() @@ -378,12 +379,43 @@ function App(props: { onSnapshot?: () => Promise; 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({ @@ -397,9 +429,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi event, sdk, sync, + data, theme: themeState, toast, renderer, + selection, attention, Slot: pluginRuntime.Slot, }), @@ -423,6 +457,12 @@ function App(props: { onSnapshot?: () => Promise; 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) }, @@ -1091,6 +1131,8 @@ function App(props: { onSnapshot?: () => Promise; 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 @@ -1098,11 +1140,19 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi 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) + }} > diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index b67923f3c5d1..abe14ca14175 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -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" @@ -24,6 +25,7 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean HomeTips, SidebarContext, SidebarMcp, + SidebarSkills, SidebarLsp, SidebarTodo, SidebarFiles, diff --git a/packages/tui/src/feature-plugins/sidebar/skill-usage.ts b/packages/tui/src/feature-plugins/sidebar/skill-usage.ts new file mode 100644 index 000000000000..1a0d71aefc38 --- /dev/null +++ b/packages/tui/src/feature-plugins/sidebar/skill-usage.ts @@ -0,0 +1,42 @@ +export type SkillUsage = Record + +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) +} diff --git a/packages/tui/src/feature-plugins/sidebar/skills.tsx b/packages/tui/src/feature-plugins/sidebar/skills.tsx new file mode 100644 index 000000000000..33d56b539ebf --- /dev/null +++ b/packages/tui/src/feature-plugins/sidebar/skills.tsx @@ -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 ( + + skills().length > 0 && setOpen((value) => !value)}> + 0}> + {open() ? "▼" : "▶"} + + + Skills ({skills().length}) + + + + + No skills registered + + + {(item) => ( + + + • + + + {item.name} + 0}> + {item.count} + + + + )} + + + + ) +} + +const tui: TuiPlugin = async (api) => { + const [usage, setUsage] = createSignal(sanitizeSkillUsage(api.kv.get(key), Date.now())) + const completed = new Set() + + 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 + }, + }, + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fef0ec8eb8dd..039e6b48c315 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -8,6 +8,7 @@ import type { useTheme } from "../context/theme" import { Dialog as DialogUI, type useDialog } from "../ui/dialog" import type { useOpencodeKeymap } from "../keymap" import type { useKV } from "../context/kv" +import type { useData } from "../context/data" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" import { DialogPrompt } from "../ui/dialog-prompt" @@ -31,9 +32,11 @@ type Input = { event: ReturnType sdk: ReturnType sync: ReturnType + data: ReturnType theme: ReturnType toast: ReturnType renderer: TuiPluginApi["renderer"] + selection: TuiPluginApi["selection"] attention: TuiPluginApi["attention"] Slot: TuiPluginApi["ui"]["Slot"] } @@ -95,7 +98,7 @@ function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { return (item: SelectOption) => cb(pickOption(item)) } -function stateApi(sync: ReturnType): TuiPluginApi["state"] { +function stateApi(sync: ReturnType, data: ReturnType): TuiPluginApi["state"] { return { get ready() { return sync.ready @@ -159,6 +162,18 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { error: item.status === "failed" ? item.error : undefined, })) }, + skill: { + list(sessionID) { + const session = sync.session.get(sessionID) + if (!session) return [] + return data.location.skill.list({ directory: session.directory, workspaceID: session.workspaceID }) ?? [] + }, + async refresh(sessionID) { + const session = sync.session.get(sessionID) + if (!session) return + await data.location.skill.refresh({ directory: session.directory, workspaceID: session.workspaceID }) + }, + }, } } @@ -297,12 +312,13 @@ export function createTuiApiAdapters(input: Input): Omit - alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> + alwaysSeparate.add(el)} + paddingLeft={3} + marginTop={1} + flexShrink={0} + > void @@ -23,6 +24,44 @@ type SelectionKeyEvent = { stopPropagation: () => void } +export type PluginSelectionPayload = { + text: string + x: number + y: number + renderables: readonly Renderable[] +} + +type SelectionRenderer = { + getSelection: () => { getSelectedText: () => string; selectedRenderables: readonly Renderable[] } | null + clearSelection: () => void +} + +export function startPluginSelection( + _renderer: Pick, + event: MouseEvent, + armed: boolean, +): boolean { + return armed && event.button === MouseButton.LEFT +} + +export function capturePluginSelection( + renderer: Pick, + event: MouseEvent, + publish: (payload: PluginSelectionPayload) => void, + started = false, +): boolean { + if (!started) return false + const selection = renderer.getSelection() + const text = selection?.getSelectedText().trim() + if (selection && text) { + publish({ text, x: event.x, y: event.y, renderables: selection.selectedRenderables }) + } + renderer.clearSelection() + event.preventDefault() + event.stopPropagation() + return true +} + export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false diff --git a/packages/tui/test/feature-plugins/skill-usage.test.ts b/packages/tui/test/feature-plugins/skill-usage.test.ts new file mode 100644 index 000000000000..5650a1010966 --- /dev/null +++ b/packages/tui/test/feature-plugins/skill-usage.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { incrementSkillUsage, rankSkills, sanitizeSkillUsage } from "../../src/feature-plugins/sidebar/skill-usage" + +describe("skill usage", () => { + test("ranks used skills by frecency before alphabetical unused skills", () => { + const now = 10 * 86_400_000 + const result = rankSkills( + ["zeta", "alpha", "recent", "frequent"], + { + recent: { count: 2, lastUsed: now }, + frequent: { count: 8, lastUsed: now - 7 * 86_400_000 }, + }, + now, + ) + + expect(result.map((item) => item.name)).toEqual(["recent", "frequent", "alpha", "zeta"]) + }) + + test("uses last use and name to break equal score ties", () => { + const now = 10 * 86_400_000 + const result = rankSkills( + ["beta", "alpha", "older"], + { + alpha: { count: 1, lastUsed: now }, + beta: { count: 1, lastUsed: now }, + older: { count: 2, lastUsed: now - 86_400_000 }, + }, + now, + ) + + expect(result.map((item) => item.name)).toEqual(["alpha", "beta", "older"]) + }) + + test("increments immutably and updates the timestamp", () => { + const usage = { alpha: { count: 2, lastUsed: 10 } } + const result = incrementSkillUsage(usage, "alpha", 20) + + expect(result).toEqual({ alpha: { count: 3, lastUsed: 20 } }) + expect(usage).toEqual({ alpha: { count: 2, lastUsed: 10 } }) + }) + + test("sanitizes invalid records and clamps future timestamps", () => { + const result = sanitizeSkillUsage( + { + valid: { count: 2, lastUsed: 50 }, + future: { count: 1, lastUsed: 200 }, + negative: { count: -1, lastUsed: 10 }, + fractional: { count: 1.5, lastUsed: 10 }, + malformed: "nope", + }, + 100, + ) + + expect(result).toEqual({ + valid: { count: 2, lastUsed: 50 }, + future: { count: 1, lastUsed: 100 }, + }) + }) + + test("limits results and ignores usage for unregistered skills", () => { + const names = Array.from({ length: 12 }, (_, index) => `skill-${index.toString().padStart(2, "0")}`) + const result = rankSkills(names, { removed: { count: 100, lastUsed: 100 } }, 100) + + expect(result).toHaveLength(10) + expect(result.map((item) => item.name)).toEqual(names.slice(0, 10)) + }) +}) diff --git a/packages/tui/test/util/selection.test.ts b/packages/tui/test/util/selection.test.ts new file mode 100644 index 000000000000..c0e93693f515 --- /dev/null +++ b/packages/tui/test/util/selection.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test" +import { MouseButton, MouseEvent, type Renderable } from "@opentui/core" +import { capturePluginSelection, copy, startPluginSelection } from "../../src/util/selection" + +function mouse(target: Renderable | null, overrides: Partial[1]> = {}) { + return new MouseEvent(target, { + type: "up", + button: MouseButton.LEFT, + x: 12, + y: 8, + modifiers: { shift: false, alt: false, ctrl: true }, + ...overrides, + }) +} + +test("marks an armed left drag without replacing OpenTUI selection ownership", () => { + let started: [Renderable, number, number] | undefined + let prevented = false + let stopped = false + const target = { + selectable: true, + shouldStartSelection: () => true, + } as unknown as Renderable & { selectable: boolean; shouldStartSelection: () => boolean } + const renderer = { + startSelection(renderable: Renderable, x: number, y: number) { + started = [renderable, x, y] + }, + clearSelection() {}, + } + const event = mouse(target, { + type: "down", + x: 4, + y: 5, + modifiers: { shift: false, alt: false, ctrl: false }, + }) + event.preventDefault = () => { prevented = true } + event.stopPropagation = () => { stopped = true } + + expect(startPluginSelection(renderer, event, false)).toBe(false) + expect(startPluginSelection(renderer, event, true)).toBe(true) + expect(started).toBeUndefined() + expect(prevented).toBe(false) + expect(stopped).toBe(false) +}) + +test("publishes armed plugin selection before clearing it", () => { + const selected = { getSelectedText: () => " English answer ", selectedRenderables: [] as readonly Renderable[] } + let cleared = false + let published: unknown + const event = mouse(null) + event.preventDefault = () => undefined + event.stopPropagation = () => undefined + + expect(capturePluginSelection({ + getSelection: () => selected, + clearSelection: () => { cleared = true }, + }, event, (value) => { published = value }, true)).toBe(true) + expect(published).toEqual({ text: "English answer", x: 12, y: 8, renderables: [] }) + expect(cleared).toBe(true) +}) + +test("keeps plugin selection ownership without mouse modifiers", () => { + const selected = { getSelectedText: () => "English answer", selectedRenderables: [] as readonly Renderable[] } + let published = false + const event = mouse(null, { modifiers: { shift: false, alt: false, ctrl: false } }) + + expect(capturePluginSelection({ + getSelection: () => selected, + clearSelection: () => undefined, + }, event, () => { published = true }, true)).toBe(true) + expect(published).toBe(true) +}) + +test("keeps ordinary copy behavior separate from Ctrl capture", async () => { + let cleared = false + let copied = "" + const renderer = { + getSelection: () => ({ + getSelectedText: () => "English answer", + selectedRenderables: [], + }), + clearSelection: () => { cleared = true }, + currentFocusedRenderable: null, + } + const clipboard = { + write: async (value: string) => { copied = value }, + } + + expect(copy(renderer, { show: () => undefined, error: () => undefined }, clipboard)).toBe(true) + await Bun.sleep(0) + expect(copied).toBe("English answer") + expect(cleared).toBe(true) +})