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
83 changes: 81 additions & 2 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { useShallow } from 'zustand/react/shallow'

Expand All @@ -32,6 +33,7 @@ import { ChatHeader } from './components/chat-header'
import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary'
import { LoadPreviousButton } from './components/load-previous-button'
import { QueuePanel } from './components/queue-panel'
import { SkillsPanel } from './components/skills-panel'
import { ReviewScreen } from './components/review-screen'
import { MessageWithAgents } from './components/message-with-agents'
import { areCreditsRestored } from './components/out-of-credits-banner'
Expand All @@ -46,6 +48,11 @@ import {
import { TopBanner } from './components/top-banner'
import { useChatRuntime } from './contexts/chat-runtime-context'
import { getSlashCommandsWithSkills } from './data/slash-commands'
import {
getSkillsVersion,
refreshSkillRegistry,
subscribeToSkillsVersion,
} from './utils/skill-registry'
import { useAskUserBridge } from './hooks/use-ask-user-bridge'
import { useChatInput } from './hooks/use-chat-input'
import {
Expand All @@ -69,6 +76,7 @@ import { getProjectRoot } from './project-files'
import { useChatHistoryStore } from './state/chat-history-store'
import { useChatStore } from './state/chat-store'
import { useQueuePanelStore } from './state/queue-panel-store'
import { useSkillsPanelStore } from './state/skills-panel-store'
import { useReviewStore } from './state/review-store'
import { useFeedbackStore } from './state/feedback-store'
import { useMessageBlockStore } from './state/message-block-store'
Expand Down Expand Up @@ -548,8 +556,19 @@ export const Chat = ({
const setInputMode = useChatStore((state) => state.setInputMode)
const askUserState = useChatStore((state) => state.askUserState)

// Get loaded skills for slash commands
const loadedSkills = useMemo(() => getLoadedSkills(), [])
// Get loaded skills for slash commands. Keyed on the registry version so a
// mid-session change (delete via the panel, edit on disk + reopen) swaps the
// list without a restart — the registry object itself is mutated in place,
// which zustand and useMemo would never notice.
const skillsVersion = useSyncExternalStore(
subscribeToSkillsVersion,
getSkillsVersion,
getSkillsVersion,
)
const loadedSkills = useMemo(() => {
void skillsVersion
return getLoadedSkills()
}, [skillsVersion])

// Filter slash commands based on current ads state - only show the option that changes state
// Hide both ads commands entirely for subscribers
Expand Down Expand Up @@ -995,6 +1014,13 @@ export const Chat = ({
})),
)

const { skillsPanelOpen, closeSkillsPanel } = useSkillsPanelStore(
useShallow((state) => ({
skillsPanelOpen: state.skillsPanelOpen,
closeSkillsPanel: state.closeSkillsPanel,
})),
)

// Review and ask_user take the composer's place too. Leaving the panel
// flagged open behind them would keep chat's keyboard disabled with nothing
// rendered to handle keys, so hand the surface back for real.
Expand All @@ -1004,11 +1030,24 @@ export const Chat = ({
}
}, [queuePanelOpen, reviewMode, askUserState, closeQueuePanel])

// Same arbitration as the queue panel: review/ask-user own the surface and
// the keyboard, so the skills panel hands them back rather than linger
// invisibly under them.
useEffect(() => {
if (skillsPanelOpen && (reviewMode || askUserState !== null)) {
closeSkillsPanel()
}
}, [skillsPanelOpen, reviewMode, askUserState, closeSkillsPanel])

// The panel store outlives this component and a Freebuff session can end on
// its own, unmounting chat mid-edit. Without this, the next session would
// open onto a panel for a queue that no longer exists.
useEffect(() => () => useQueuePanelStore.getState().closeQueuePanel(), [])

// A Freebuff session can end on its own, unmounting chat mid-panel; without
// this the next session would open onto a stale skills panel.
useEffect(() => () => useSkillsPanelStore.getState().closeSkillsPanel(), [])

const publishMutation = usePublishMutation()

const handleCommandResult = useCallback(
Expand Down Expand Up @@ -1051,6 +1090,10 @@ export const Chat = ({
if (queuedCount > 0) useQueuePanelStore.getState().openQueuePanel()
else setMessages((prev) => [...prev, getSystemMessage('Nothing queued.')])
}

if (result.openSkillsPanel) {
useSkillsPanelStore.getState().openSkillsPanel()
}
},
[
saveCurrentInput,
Expand Down Expand Up @@ -1220,6 +1263,32 @@ export const Chat = ({
inputRef.current?.focus()
}, [closeQueuePanel, setInputFocused, inputRef])

const handleCloseSkillsPanel = useCallback(() => {
closeSkillsPanel()
setInputFocused(true)
inputRef.current?.focus()
}, [closeSkillsPanel, setInputFocused, inputRef])

// Refresh the registry when the /skills panel opens, so a skill installed
// or edited moments ago shows up without restarting the CLI.
useEffect(() => {
if (!skillsPanelOpen) return
void refreshSkillRegistry()
}, [skillsPanelOpen])

// Invoking from the panel closes it and drops into the existing skill input
// mode — the exact path /skill:<name> takes, so the two entries cannot
// drift. Focus returns first so the composer receives what the user types.
const handleSkillsPanelInvoke = useCallback(
(name: string) => {
closeSkillsPanel()
setInputFocused(true)
inputRef.current?.focus()
useChatStore.getState().enterSkillMode(name)
},
[closeSkillsPanel, setInputFocused, inputRef],
)

const handleReviewCustom = useCallback(() => {
closeReviewScreen()
setInputMode('review')
Expand Down Expand Up @@ -1594,6 +1663,7 @@ export const Chat = ({
askUserState !== null ||
reviewMode ||
queuePanelOpen ||
skillsPanelOpen ||
sponsoredProposalMenuOpen,
})

Expand Down Expand Up @@ -1771,6 +1841,7 @@ export const Chat = ({
askUserState !== null ||
reviewMode ||
queuePanelOpen ||
skillsPanelOpen ||
sponsoredProposalMenuOpen ||
isFreebuffSessionOver
useEffect(() => {
Expand Down Expand Up @@ -1955,6 +2026,14 @@ export const Chat = ({
width={separatorWidth}
maxVisibleRows={isCompactHeight ? 4 : 8}
/>
) : skillsPanelOpen && !askUserState ? (
<SkillsPanel
skills={Object.values(loadedSkills)}
onInvoke={handleSkillsPanelInvoke}
onClose={handleCloseSkillsPanel}
width={separatorWidth}
maxVisibleRows={isCompactHeight ? 4 : 8}
/>
) : isFreebuffSessionOver && !askUserState ? (
<SessionEndedBanner
isStreaming={isStreaming || isWaitingForResponse}
Expand Down
98 changes: 98 additions & 0 deletions cli/src/commands/__tests__/skills-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'

import { useChatStore } from '../../state/chat-store'
import {
__resetSkillRegistryForTests,
__setSkillsForTests,
} from '../../utils/skill-registry'
import { findCommand } from '../command-registry'

import type { RouterParams } from '../command-registry'
import type { SkillDefinition } from '@codebuff/common/types/skill'

const PROJECT_SKILL: SkillDefinition = {
name: 'release-notes',
description: 'Draft release notes from recent commits',
content: '---\nname: release-notes\ndescription: Draft release notes\n---',
filePath: '/project/.agents/skills/release-notes/SKILL.md',
}

const GLOBAL_SKILL: SkillDefinition = {
name: 'git-helper',
description: 'Helpful git workflows',
content: '---\nname: git-helper\ndescription: Helpful git workflows\n---',
filePath: '/home/user/.agents/skills/git-helper/SKILL.md',
}

const createMockParams = (overrides: Partial<RouterParams> = {}): RouterParams =>
({
agentMode: 'DEFAULT',
inputRef: { current: null },
inputValue: '/skills',
isChainInProgressRef: { current: false },
isStreaming: false,
logoutMutation: {} as RouterParams['logoutMutation'],
streamMessageIdRef: { current: null },
addToQueue: mock(() => {}),
clearMessages: mock(() => {}),
saveToHistory: mock(() => {}),
scrollToLatest: mock(() => {}),
sendMessage: mock(async () => {}),
setCanProcessQueue: mock(() => {}),
setInputFocused: mock(() => {}),
setInputValue: mock(() => {}),
setIsAuthenticated: mock(() => {}),
setMessages: mock(() => {}),
setUser: mock(() => {}),
...overrides,
}) as RouterParams

const resetChatStore = () => {
useChatStore.getState().setInputMode('default')
useChatStore.getState().setPendingSkillName(null)
}

beforeEach(() => {
__setSkillsForTests({
[PROJECT_SKILL.name]: PROJECT_SKILL,
[GLOBAL_SKILL.name]: GLOBAL_SKILL,
})
})

afterEach(() => {
__resetSkillRegistryForTests()
})

describe('/skills command', () => {
test('opens the panel when skills are loaded', async () => {
const command = findCommand('skills')
expect(command).toBeDefined()

const params = createMockParams()
const result = await command!.handler(params, '')

expect(result).toMatchObject({ openSkillsPanel: true })
expect(params.sendMessage).not.toHaveBeenCalled()
})

test('reports install guidance instead of opening an empty panel', async () => {
__resetSkillRegistryForTests()

const command = findCommand('skills')
const params = createMockParams()
const result = await command!.handler(params, '')

expect(result).toBeUndefined()
expect(params.setMessages).toHaveBeenCalledTimes(1)
const [updater] = (params.setMessages as ReturnType<typeof mock>).mock
.calls[0] as [(prev: unknown[]) => unknown[]]
const messages = updater([]) as { role: string; content: string }[]
const last = messages[messages.length - 1]
expect(last.content).toContain('No skills loaded')
expect(last.content).toContain('npx skills add')
})

test('is reachable through the skill alias', () => {
expect(findCommand('skill')).toBeDefined()
})
})
24 changes: 23 additions & 1 deletion cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { AGENT_MODES, END_SESSION_MESSAGE, IS_FREEBUFF } from '../utils/constant
import { exitCliCleanly } from '../utils/exit-cleanly'
import { getSystemMessage, getUserMessage } from '../utils/message-history'
import { capturePendingAttachments } from '../utils/pending-attachments'
import { getSkillByName } from '../utils/skill-registry'
import { getSkillByName, getSkillCount } from '../utils/skill-registry'

import type { MultilineInputHandle } from '../components/multiline-input'
import type { InputValue, PendingAttachment } from '../types/store'
Expand Down Expand Up @@ -81,6 +81,7 @@ export type CommandResult = {
openChatHistory?: boolean
openReviewScreen?: boolean
openQueuePanel?: boolean
openSkillsPanel?: boolean
preSelectAgents?: string[]
} | void

Expand Down Expand Up @@ -719,6 +720,27 @@ const ALL_COMMANDS: CommandDefinition[] = [
return { openQueuePanel: true }
},
}),
defineCommand({
name: 'skills',
aliases: ['skill'],
handler: (params) => {
if (getSkillCount() === 0) {
params.setMessages((prev) => [
...prev,
getUserMessage(params.inputValue.trim()),
getSystemMessage(
'No skills loaded.\n\nSkills load from:\n - ~/.claude/skills/ (global, Claude Code compatible)\n - ~/.agents/skills/ (global)\n - .claude/skills/ (project, Claude Code compatible)\n - .agents/skills/ (project, overrides global)\n\nInstall some with: npx skills add <owner/repo>\nNew and changed skills are picked up live — no restart needed.',
),
])
params.saveToHistory(params.inputValue.trim())
clearInput(params)
return
}
params.saveToHistory(params.inputValue.trim())
clearInput(params)
return { openSkillsPanel: true }
},
}),
defineCommand({
name: 'theme:toggle',
handler: (params) => {
Expand Down
Loading
Loading