Skip to content
Open
2 changes: 1 addition & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
filterKeys: ["trigger", "title"],
filterKeys: ["trigger", "title", "description"],
onSelect: handleSlashSelect,
})

Expand Down
52 changes: 50 additions & 2 deletions packages/app/src/components/prompt-input/slash-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Component, For, Match, Show, Switch } from "solid-js"
import { Component, For, Match, Show, Switch, createMemo } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { getTopRecentSkills, recordSkillUsage } from "@opencode-ai/core/util/recent-skills"

export type AtOption =
| { type: "agent"; name: string; display: string }
Expand Down Expand Up @@ -34,6 +35,8 @@ type PromptPopoverProps = {
}

export const PromptPopover: Component<PromptPopoverProps> = (props) => {
const recentNames = createMemo(() => getTopRecentSkills(5))

return (
<Show when={props.popover}>
<div
Expand Down Expand Up @@ -98,6 +101,48 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
when={props.slashFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
>
<Show when={recentNames().length > 0}>
<div class="px-2 py-1 text-11-medium text-text-subtle uppercase tracking-wide">Recently Used</div>
<For each={recentNames()}>
{(name) => {
const cmd = props.slashFlat.find((c) => c.id === name)
if (!cmd) return null
return (
<button
data-slash-id={cmd.id}
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
}}
onClick={() => {
recordSkillUsage(cmd.id)
props.onSlashSelect(cmd)
}}
onMouseEnter={() => props.setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
<Show when={cmd.description}>
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
</Show>
</div>
</button>
)
}}
</For>
<div class="border-t border-border-base my-1" />
</Show>
<For each={props.slashFlat}>
{(cmd) => (
<button
Expand All @@ -106,7 +151,10 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
}}
onClick={() => props.onSlashSelect(cmd)}
onClick={() => {
if (cmd.source === "skill") recordSkillUsage(cmd.id)
props.onSlashSelect(cmd)
}}
onMouseEnter={() => props.setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/util/recent-skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdir } from "fs/promises"
import { dirname } from "path"
import { Global } from "../global"

const STORAGE_FILE = "recent-skills.json"
const MAX_RECENT = 50
const TTL_MS = 30 * 24 * 60 * 60 * 1000

type RecentEntry = {
name: string
usedAt: string
}

const state: { cache: RecentEntry[] | undefined } = { cache: undefined }

function filePath() {
return `${Global.Path.state}/${STORAGE_FILE}`
}

function saveToDisk(entries: RecentEntry[]) {
const path = filePath()
mkdir(dirname(path), { recursive: true })
.then(() => Bun.write(path, JSON.stringify(entries, null, 2)))
.catch(() => {})
}

function initCache(): RecentEntry[] {
if (state.cache !== undefined) return state.cache
state.cache = []
Bun.file(filePath())
.text()
.then((raw) => {
const parsed: unknown = JSON.parse(raw)
if (Array.isArray(parsed)) state.cache = parsed as RecentEntry[]
})
.catch(() => {})
return state.cache
}

function getRecentSkills(): RecentEntry[] {
const now = Date.now()
const seen = new Set<string>()
return initCache().filter((entry) => {
const ts = new Date(entry.usedAt).getTime()
if (now - ts > TTL_MS) return false
if (seen.has(entry.name)) return false
seen.add(entry.name)
return true
})
}

export function getTopRecentSkills(n: number): string[] {
return getRecentSkills()
.slice(0, n)
.map((e) => e.name)
}

export function recordSkillUsage(name: string): void {
const updated = [
{ name, usedAt: new Date().toISOString() },
...initCache().filter((e) => e.name !== name),
].slice(0, MAX_RECENT)
state.cache = updated
saveToDisk(updated)
}
26 changes: 24 additions & 2 deletions packages/opencode/src/cli/cmd/run/footer.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { recordSkillUsage, getTopRecentSkills } from "@opencode-ai/core/util/recent-skills"
import type { RunFooterTheme } from "./theme"
import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"

Expand Down Expand Up @@ -37,6 +38,7 @@ type VariantEntry = PanelEntry & {

type SkillEntry = PanelEntry & {
name: string
template?: string
}

type SubagentEntry = PanelEntry & {
Expand Down Expand Up @@ -211,6 +213,7 @@ function PanelShell(props: {
theme: Accessor<RunFooterTheme>
inputRef: (input: InputRenderable) => void
onQuery: (query: string) => void
onKeyDown?: (event: KeyEvent) => void
children: JSX.Element
dark?: boolean
chrome?: "default" | "minimal"
Expand Down Expand Up @@ -260,6 +263,7 @@ function PanelShell(props: {
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
onKeyDown={props.onKeyDown}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
Expand Down Expand Up @@ -783,17 +787,35 @@ export function RunSkillSelectBody(props: {
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
keywords: `skill ${item.name} ${item.description ?? ""}`,
name: item.name,
template: item.template,
}))
.sort((a, b) => a.display.localeCompare(b.display)),
)
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
const recentEntries = createMemo<SkillEntry[]>(() => {
if (query()) return []
return getTopRecentSkills(5).map((recent) => {
const full = entries().find((e) => e.name === recent)
return {
category: "",
display: recent,
description: full?.description,
keywords: `skill ${recent}`,
name: recent,
template: full?.template,
}
})
})
const allEntries = createMemo<SkillEntry[]>(() => [...recentEntries(), ...entries()])
const filteredEntries = createMemo<SkillEntry[]>(() => match(query(), allEntries()))
const items = filteredEntries
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
const select = () => {
const item = items()[menu.selected()]
if (!item) {
return
}

recordSkillUsage(item.name)
props.onSelect(item.name)
}

Expand All @@ -815,7 +837,7 @@ export function RunSkillSelectBody(props: {
title="Skills"
query={query()}
count={items().length}
total={entries().length}
total={allEntries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
Expand Down
64 changes: 64 additions & 0 deletions packages/tui/src/component/dialog-skill-detail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useKeyboard } from "@opentui/solid"
import { Show } from "solid-js"

export type DialogSkillDetailProps = {
name: string
description?: string
template?: string
onSelect: (skill: string) => void
}

export function DialogSkillDetail(props: DialogSkillDetailProps) {
const dialog = useDialog()
const { theme } = useTheme()

useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onSelect(props.name)
dialog.clear()
}
})

return (
<box flexDirection="column" flexGrow={1}>
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.name}
</text>
<text fg={theme.textMuted}>enter: use · esc: back</text>
</box>
<Show when={props.description}>
<box paddingTop={1}>
<text fg={theme.textMuted} wrapMode="word">
{props.description}
</text>
</box>
</Show>
</box>
<Show when={!!props.template}>
<box flexGrow={1} flexShrink={1} maxHeight={20}>
<scrollbox
paddingLeft={4}
paddingRight={4}
horizontalScrollbarOptions={{ visible: false }}
>
<text fg={theme.text} wrapMode="word">
{props.template}
</text>
</scrollbox>
</box>
</Show>
<Show when={!!!props.template}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No template available</text>
</box>
</Show>
</box>
)
}
66 changes: 63 additions & 3 deletions packages/tui/src/component/dialog-skill.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo } from "solid-js"
import { DialogSkillDetail } from "./dialog-skill-detail"
import { createResource, createMemo, createSignal } from "solid-js"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useKeyboard } from "@opentui/solid"
import { getTopRecentSkills, recordSkillUsage } from "@opencode-ai/core/util/recent-skills"

export type DialogSkillProps = {
onSelect: (skill: string) => void
Expand All @@ -12,25 +15,82 @@ export function DialogSkill(props: DialogSkillProps) {
const sdk = useSDK()
dialog.setSize("large")

const [selectedIndex, setSelectedIndex] = createSignal(0)

const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})

function pushDetail(skillName: string) {
const skill = (skills() ?? []).find((s) => s.name === skillName)
if (!skill) return
dialog.push(() => (
<DialogSkillDetail
name={skill.name}
description={skill.description}
template={skill.content}
onSelect={props.onSelect}
/>
))
}

const options = createMemo<DialogSelectOption<string>[]>(() => {
const list = skills() ?? []
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
return list.map((skill) => ({

const recentOpts: DialogSelectOption<string>[] = getTopRecentSkills(5).map((name) => ({
title: name.padEnd(maxWidth),
description: undefined,
value: name,
trailing: " ›",
onTrailingClick: () => pushDetail(name),
category: "Recently Used",
onSelect: () => {
recordSkillUsage(name)
props.onSelect(name)
dialog.clear()
},
}))

const allOpts: DialogSelectOption<string>[] = list.map((skill) => ({
title: skill.name.padEnd(maxWidth),
description: skill.description?.replace(/\s+/g, " ").trim(),
value: skill.name,
trailing: " ›",
onTrailingClick: () => pushDetail(skill.name),
category: "Skills",
onSelect: () => {
recordSkillUsage(skill.name)
props.onSelect(skill.name)
dialog.clear()
},
}))

return [...recentOpts, ...allOpts]
})

useKeyboard((evt) => {
if (evt.ctrl && evt.name === "o") {
evt.preventDefault()
evt.stopPropagation()
const idx = selectedIndex()
const opts = options()
if (idx < 0 || idx >= opts.length) return
pushDetail(opts[idx].value)
}
})

return <DialogSelect title="Skills" placeholder="Search skills..." options={options()} />
return (
<DialogSelect
title="Skills"
placeholder="Search skills..."
options={options()}
selectedIndex={selectedIndex()}
onIndexChange={(index) => setSelectedIndex(index)}
footerHints={[
{ title: "ctrl+o", label: "expand", side: "right" },
]}
/>
)
}
Loading
Loading