From 3d95e19dd3239b0e32a33df1aa48dd7b033e3ff4 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 20 Jan 2026 17:02:47 +0800 Subject: [PATCH] feat: add slash command support for ACP agents - Add AvailableCommand type re-export from ACP SDK - Handle available_commands_update events in AcpClientFactory - Store available commands in SessionAgent state - Add IPC channel and handler for fetching session commands - Create commandStore for renderer state management - Update useApp hook to fetch and listen for command updates - Create SlashCommandMenu component with keyboard navigation - Integrate slash command detection and validation in MessageInput - Add unit tests for slash command utilities and store Commands are defined by ACP agents and sent as plain text for the agent to parse and execute. The client provides autocomplete and validation based on the available commands list. Co-Authored-By: Claude Opus 4.5 --- src/main/conductor/AcpClientFactory.ts | 10 ++ src/main/conductor/AgentProcessManager.ts | 10 +- src/main/conductor/types.ts | 3 + src/main/ipc/handlers.ts | 5 + src/preload/index.ts | 4 + src/renderer/src/components/MessageInput.tsx | 80 +++++++++- .../src/components/SlashCommandMenu.tsx | 141 ++++++++++++++++++ src/renderer/src/hooks/useApp.ts | 21 ++- src/renderer/src/stores/commandStore.ts | 32 ++++ src/renderer/src/utils/slashCommand.ts | 41 +++++ src/shared/electron-api.d.ts | 6 +- src/shared/ipc-channels.ts | 1 + src/shared/types.ts | 3 + .../components/SlashCommandMenu.test.ts | 71 +++++++++ .../unit/renderer/stores/commandStore.test.ts | 56 +++++++ 15 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/SlashCommandMenu.tsx create mode 100644 src/renderer/src/stores/commandStore.ts create mode 100644 src/renderer/src/utils/slashCommand.ts create mode 100644 tests/unit/renderer/components/SlashCommandMenu.test.ts create mode 100644 tests/unit/renderer/stores/commandStore.test.ts diff --git a/src/main/conductor/AcpClientFactory.ts b/src/main/conductor/AcpClientFactory.ts index e52d879297..450173dad5 100644 --- a/src/main/conductor/AcpClientFactory.ts +++ b/src/main/conductor/AcpClientFactory.ts @@ -11,6 +11,7 @@ import type { SessionModeId, ModelId } from '@agentclientprotocol/sdk' +import type { AvailableCommand } from '@agentclientprotocol/sdk/dist/schema/types.gen' import type { SessionStore } from '../session/SessionStore' export interface AcpClientCallbacks { @@ -20,6 +21,8 @@ export interface AcpClientCallbacks { onModeUpdate?: (modeId: SessionModeId) => void /** Called when server sends a model update notification */ onModelUpdate?: (modelId: ModelId) => void + /** Called when server sends available commands update */ + onAvailableCommandsUpdate?: (commands: AvailableCommand[]) => void } export interface AcpClientFactoryOptions { @@ -69,6 +72,13 @@ export function createAcpClient(sessionId: string, options: AcpClientFactoryOpti callbacks.onModeUpdate(modeUpdate.currentModeId) } } + // Handle available commands update notification + if (updateType === 'available_commands_update' && callbacks.onAvailableCommandsUpdate) { + const commandsUpdate = update as { availableCommands?: AvailableCommand[] } + if (commandsUpdate.availableCommands) { + callbacks.onAvailableCommandsUpdate(commandsUpdate.availableCommands) + } + } } else { console.log(`[ACP] raw update:`, params) } diff --git a/src/main/conductor/AgentProcessManager.ts b/src/main/conductor/AgentProcessManager.ts index 9ba2e47358..a4fc3be4ee 100644 --- a/src/main/conductor/AgentProcessManager.ts +++ b/src/main/conductor/AgentProcessManager.ts @@ -76,6 +76,13 @@ export class AgentProcessManager implements IAgentProcessManager { if (sessionAgent?.sessionModelState) { sessionAgent.sessionModelState.currentModelId = modelId } + }, + // Handle available commands updates + onAvailableCommandsUpdate: (commands) => { + const sessionAgent = this.sessions.get(sessionId) + if (sessionAgent) { + sessionAgent.availableCommands = commands + } } } }), @@ -124,7 +131,8 @@ export class AgentProcessManager implements IAgentProcessManager { agentSessionId: acpResult.sessionId, needsHistoryReplay: isResumed, // True when resuming, agent needs conversation context sessionModeState, - sessionModelState + sessionModelState, + availableCommands: [] // Will be populated by available_commands_update } this.sessions.set(sessionId, sessionAgent) diff --git a/src/main/conductor/types.ts b/src/main/conductor/types.ts index 7686f937b1..627e5d92d8 100644 --- a/src/main/conductor/types.ts +++ b/src/main/conductor/types.ts @@ -12,6 +12,7 @@ import type { SessionModeState, SessionModelState } from '@agentclientprotocol/sdk' +import type { AvailableCommand } from '@agentclientprotocol/sdk/dist/schema/types.gen' import type { AgentProcess } from './AgentProcess' import type { AgentConfig, @@ -42,6 +43,8 @@ export interface SessionAgent { sessionModeState: SessionModeState | null /** Model state from ACP server (available models and current model) */ sessionModelState: SessionModelState | null + /** Available slash commands from ACP server */ + availableCommands: AvailableCommand[] } /** diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index db03bea4b8..9b5afdc96c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -211,6 +211,11 @@ export function registerIPCHandlers(conductor: Conductor): void { return sessionAgent?.sessionModelState ?? null }) + ipcMain.handle(IPC_CHANNELS.SESSION_GET_COMMANDS, async (_event, sessionId: string) => { + const sessionAgent = conductor.getSessionAgent(sessionId) + return sessionAgent?.availableCommands ?? [] + }) + ipcMain.handle( IPC_CHANNELS.SESSION_SET_MODE, async (_event, sessionId: string, modeId: SessionModeId) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 072be8e3ad..825504d7f4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -46,6 +46,10 @@ const electronAPI: ElectronAPI = { getSessionModels: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_GET_MODELS, sessionId), + // Slash commands + getSessionCommands: (sessionId: string) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_GET_COMMANDS, sessionId), + setSessionMode: (sessionId: string, modeId: SessionModeId) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_SET_MODE, sessionId, modeId), diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx index ac336ad100..af50d7e923 100644 --- a/src/renderer/src/components/MessageInput.tsx +++ b/src/renderer/src/components/MessageInput.tsx @@ -1,19 +1,22 @@ /** - * Message input component with image upload support + * Message input component with image upload and slash command support */ -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { ArrowUp, Square, Paperclip, X, AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { AgentSelector } from './AgentSelector' import { ModeSelector } from './ModeSelector' import { ModelSelector } from './ModelSelector' +import { SlashCommandMenu, parseSlashCommand, validateCommand } from './SlashCommandMenu' +import { useCommandStore } from '../stores/commandStore' import type { MessageContent, ImageContentItem } from '../../../shared/types/message' import type { SessionModeState, SessionModelState, SessionModeId, - ModelId + ModelId, + AvailableCommand } from '../../../shared/types' interface MessageInputProps { @@ -87,8 +90,38 @@ export function MessageInput({ const [value, setValue] = useState('') const [isComposing, setIsComposing] = useState(false) const [images, setImages] = useState([]) + const [showCommandMenu, setShowCommandMenu] = useState(false) + const [commandMenuIndex, setCommandMenuIndex] = useState(0) + const [commandError, setCommandError] = useState(null) const textareaRef = useRef(null) const fileInputRef = useRef(null) + const inputContainerRef = useRef(null) + + // Get available commands from store + const availableCommands = useCommandStore((state) => state.availableCommands) + + // Parse current slash command state + const parsedCommand = useMemo(() => parseSlashCommand(value), [value]) + const commandFilter = parsedCommand?.command || '' + const isInCommandMode = parsedCommand !== null && parsedCommand.argument === undefined + + // Update command menu visibility when typing + useEffect(() => { + if (isInCommandMode && availableCommands.length > 0) { + setShowCommandMenu(true) + setCommandMenuIndex(0) + setCommandError(null) + } else { + setShowCommandMenu(false) + // Validate command when not in autocomplete mode (has argument or space after command) + if (parsedCommand && parsedCommand.command && availableCommands.length > 0) { + const error = validateCommand(value, availableCommands) + setCommandError(error) + } else { + setCommandError(null) + } + } + }, [isInCommandMode, availableCommands, parsedCommand, value]) // Auto-resize textarea useEffect(() => { @@ -192,11 +225,27 @@ export function MessageInput({ setImages((prev) => prev.filter((_, i) => i !== index)) }, []) + // Handle slash command selection from menu + const handleCommandSelect = useCallback((command: AvailableCommand) => { + // Replace the current "/" text with the selected command + const hasInput = command.input + setValue(`/${command.name}${hasInput ? ' ' : ''}`) + setShowCommandMenu(false) + setCommandError(null) + // Focus back on textarea + textareaRef.current?.focus() + }, []) + // Handle submit const handleSubmit = useCallback(() => { const trimmed = value.trim() if ((!trimmed && images.length === 0) || disabled || isProcessing) return + // Check for command errors before sending + if (commandError) { + return + } + // Build message content array const content: MessageContent = [] @@ -213,9 +262,14 @@ export function MessageInput({ onSend(content) setValue('') setImages([]) - }, [value, images, disabled, isProcessing, onSend]) + setCommandError(null) + }, [value, images, disabled, isProcessing, onSend, commandError]) const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle Enter/Tab/arrows while command menu is open (SlashCommandMenu handles these) + if (showCommandMenu && ['Enter', 'Tab', 'ArrowUp', 'ArrowDown', 'Escape'].includes(e.key)) { + return + } // Don't submit while IME is composing if (e.key === 'Enter' && !e.shiftKey && !isComposing) { e.preventDefault() @@ -223,7 +277,7 @@ export function MessageInput({ } } - const canSubmit = !disabled && (value.trim().length > 0 || images.length > 0) + const canSubmit = !disabled && !commandError && (value.trim().length > 0 || images.length > 0) const hasFolder = !!workingDirectory // Don't render when no folder is selected @@ -235,7 +289,18 @@ export function MessageInput({ return (
{directoryExists === false && } -
+
+ {/* Slash command autocomplete menu */} + setShowCommandMenu(false)} + visible={showCommandMenu} + /> +
{/* Image previews */} {images.length > 0 && ( @@ -276,6 +341,9 @@ export function MessageInput({ className="w-full resize-none bg-transparent px-1 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" /> + {/* Command error message */} + {commandError &&
{commandError}
} + {/* Hidden file input */} void + /** Callback when selected index changes */ + onIndexChange: (index: number) => void + /** Callback when menu should close */ + onClose: () => void + /** Whether the menu is visible */ + visible: boolean +} + +export function SlashCommandMenu({ + commands, + filter, + selectedIndex, + onSelect, + onIndexChange, + onClose, + visible +}: SlashCommandMenuProps) { + const menuRef = useRef(null) + const itemRefs = useRef>(new Map()) + + // Filter commands based on input + const filteredCommands = commands.filter((cmd) => + cmd.name.toLowerCase().startsWith(filter.toLowerCase()) + ) + + // Clamp selected index to valid range + const clampedIndex = Math.max(0, Math.min(selectedIndex, filteredCommands.length - 1)) + + // Scroll selected item into view + useEffect(() => { + const selectedItem = itemRefs.current.get(clampedIndex) + if (selectedItem && menuRef.current) { + selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + }, [clampedIndex]) + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!visible || filteredCommands.length === 0) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + onIndexChange((clampedIndex + 1) % filteredCommands.length) + break + case 'ArrowUp': + e.preventDefault() + onIndexChange((clampedIndex - 1 + filteredCommands.length) % filteredCommands.length) + break + case 'Enter': + case 'Tab': + e.preventDefault() + if (filteredCommands[clampedIndex]) { + onSelect(filteredCommands[clampedIndex]) + } + break + case 'Escape': + e.preventDefault() + onClose() + break + } + }, + [visible, filteredCommands, clampedIndex, onIndexChange, onSelect, onClose] + ) + + // Register keyboard listener + useEffect(() => { + if (visible) { + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + } + return undefined + }, [visible, handleKeyDown]) + + // Don't render if not visible or no matching commands + if (!visible || filteredCommands.length === 0) { + return null + } + + return ( +
+
+ {filteredCommands.map((command, index) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 924711d676..d41f449916 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -14,6 +14,7 @@ import type { RunningSessionsStatus } from '../../../shared/electron-api' import type { MessageContent } from '../../../shared/types/message' import { usePermissionStore } from '../stores/permissionStore' import { useFileChangeStore } from '../stores/fileChangeStore' +import { useCommandStore } from '../stores/commandStore' import { toast } from 'sonner' import { getErrorMessage } from '../utils/error' @@ -145,6 +146,8 @@ export function useApp(): AppState & AppActions { useEffect(() => { // Get triggerRefresh from store for file change detection const triggerRefresh = useFileChangeStore.getState().triggerRefresh + // Get setAvailableCommands from store for command updates + const setAvailableCommands = useCommandStore.getState().setAvailableCommands const unsubMessage = window.electronAPI.onAgentMessage((message) => { // Only process messages for the current session @@ -184,6 +187,16 @@ export function useApp(): AppState & AppActions { } } + // Handle available_commands_update event: update slash commands store + if (update?.sessionUpdate === 'available_commands_update') { + const commandsUpdate = update as { availableCommands?: unknown[] } + if (commandsUpdate.availableCommands) { + setAvailableCommands( + commandsUpdate.availableCommands as Parameters[0] + ) + } + } + // Pass through original update without any accumulation // ChatView is responsible for accumulating chunks into complete messages // Include sequence number for proper ordering of concurrent updates @@ -247,17 +260,20 @@ export function useApp(): AppState & AppActions { // Load mode/model state for current session (if agent supports it) const loadSessionModeModel = useCallback(async (sessionId: string) => { try { - const [modes, models] = await Promise.all([ + const [modes, models, commands] = await Promise.all([ window.electronAPI.getSessionModes(sessionId), - window.electronAPI.getSessionModels(sessionId) + window.electronAPI.getSessionModels(sessionId), + window.electronAPI.getSessionCommands(sessionId) ]) setSessionModeState(modes) setSessionModelState(models) + useCommandStore.getState().setAvailableCommands(commands) } catch (err) { console.error('Failed to load session mode/model:', err) // Reset to null on error (agent may not support modes/models) setSessionModeState(null) setSessionModelState(null) + useCommandStore.getState().clearCommands() } }, []) @@ -383,6 +399,7 @@ export function useApp(): AppState & AppActions { setSessionUpdates([]) setSessionModeState(null) setSessionModelState(null) + useCommandStore.getState().clearCommands() }, []) const sendPrompt = useCallback( diff --git a/src/renderer/src/stores/commandStore.ts b/src/renderer/src/stores/commandStore.ts new file mode 100644 index 0000000000..62f9c33567 --- /dev/null +++ b/src/renderer/src/stores/commandStore.ts @@ -0,0 +1,32 @@ +/** + * Slash command state management + * + * Stores available commands for the current session, updated via: + * 1. Initial fetch when switching sessions (getSessionCommands) + * 2. Real-time updates from available_commands_update events + */ +import { create } from 'zustand' +import type { AvailableCommand } from '../../../shared/types' + +interface CommandStore { + /** Available slash commands for the current session */ + availableCommands: AvailableCommand[] + + /** Set available commands (replaces all) */ + setAvailableCommands: (commands: AvailableCommand[]) => void + + /** Clear available commands (on session switch) */ + clearCommands: () => void +} + +export const useCommandStore = create((set) => ({ + availableCommands: [], + + setAvailableCommands: (commands) => { + set({ availableCommands: commands }) + }, + + clearCommands: () => { + set({ availableCommands: [] }) + } +})) diff --git a/src/renderer/src/utils/slashCommand.ts b/src/renderer/src/utils/slashCommand.ts new file mode 100644 index 0000000000..c690f7c870 --- /dev/null +++ b/src/renderer/src/utils/slashCommand.ts @@ -0,0 +1,41 @@ +/** + * Slash command utility functions + */ +import type { AvailableCommand } from '../../../shared/types' + +/** + * Parse slash command from input text + * Returns the command name and optional argument if input starts with "/" + */ +export function parseSlashCommand(text: string): { command?: string; argument?: string } | null { + if (!text.startsWith('/')) return null + const match = text.match(/^\/(\S*)(?:\s+(.*))?$/) + if (!match) return null + return { command: match[1], argument: match[2] } +} + +/** + * Validate if a command is available + * Returns error message if invalid, null if valid + */ +export function validateCommand( + text: string, + availableCommands: AvailableCommand[] +): string | null { + const parsed = parseSlashCommand(text) + if (!parsed) return null // Not a command + + // Empty command name is ok during typing + if (!parsed.command) return null + + // If no commands available, skip validation (agent doesn't support slash commands) + if (availableCommands.length === 0) return null + + // Check if command exists + const isValid = availableCommands.some((cmd) => cmd.name === parsed.command) + if (!isValid) { + return `/${parsed.command} is not a valid command` + } + + return null +} diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 44e650665d..13d1531121 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -10,7 +10,8 @@ import type { SessionModeState, SessionModelState, SessionModeId, - ModelId + ModelId, + AvailableCommand } from './types' import type { MessageContent } from './types/message' @@ -177,6 +178,9 @@ export interface ElectronAPI { setSessionMode(sessionId: string, modeId: SessionModeId): Promise setSessionModel(sessionId: string, modelId: ModelId): Promise + // Slash commands + getSessionCommands(sessionId: string): Promise + // Configuration getConfig(): Promise updateConfig(config: Partial): Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index da74e291df..fb9e29cae6 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -24,6 +24,7 @@ export const IPC_CHANNELS = { SESSION_GET_MODELS: 'session:get-models', // Get current session's available models SESSION_SET_MODE: 'session:set-mode', // Set session mode SESSION_SET_MODEL: 'session:set-model', // Set session model + SESSION_GET_COMMANDS: 'session:get-commands', // Get current session's available slash commands // Configuration CONFIG_GET: 'config:get', diff --git a/src/shared/types.ts b/src/shared/types.ts index 88ac246ff5..4026ce27e4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -85,3 +85,6 @@ export * from './types/session' // Re-export mode/model types from ACP SDK for frontend use export * from './types/mode' export * from './types/model' + +// Re-export AvailableCommand type from ACP SDK +export type { AvailableCommand } from '@agentclientprotocol/sdk/dist/schema/types.gen' diff --git a/tests/unit/renderer/components/SlashCommandMenu.test.ts b/tests/unit/renderer/components/SlashCommandMenu.test.ts new file mode 100644 index 0000000000..13d5b69d48 --- /dev/null +++ b/tests/unit/renderer/components/SlashCommandMenu.test.ts @@ -0,0 +1,71 @@ +/** + * Tests for slash command utility functions + */ +import { describe, it, expect } from 'vitest' +import { parseSlashCommand, validateCommand } from '../../../../src/renderer/src/utils/slashCommand' +import type { AvailableCommand } from '../../../../src/shared/types' + +describe('SlashCommandMenu', () => { + describe('parseSlashCommand', () => { + it('should return null for non-command input', () => { + expect(parseSlashCommand('hello')).toBeNull() + expect(parseSlashCommand(' /hello')).toBeNull() + expect(parseSlashCommand('')).toBeNull() + }) + + it('should parse command without argument', () => { + expect(parseSlashCommand('/')).toEqual({ command: '', argument: undefined }) + expect(parseSlashCommand('/help')).toEqual({ command: 'help', argument: undefined }) + expect(parseSlashCommand('/create_plan')).toEqual({ + command: 'create_plan', + argument: undefined + }) + }) + + it('should parse command with argument', () => { + expect(parseSlashCommand('/help me')).toEqual({ command: 'help', argument: 'me' }) + expect(parseSlashCommand('/search hello world')).toEqual({ + command: 'search', + argument: 'hello world' + }) + }) + + it('should handle command with empty argument after space', () => { + expect(parseSlashCommand('/help ')).toEqual({ command: 'help', argument: '' }) + }) + }) + + describe('validateCommand', () => { + const mockCommands: AvailableCommand[] = [ + { name: 'help', description: 'Show help' }, + { name: 'search', description: 'Search the codebase' }, + { name: 'create_plan', description: 'Create a plan' } + ] + + it('should return null for non-command input', () => { + expect(validateCommand('hello', mockCommands)).toBeNull() + expect(validateCommand('', mockCommands)).toBeNull() + }) + + it('should return null for valid command', () => { + expect(validateCommand('/help', mockCommands)).toBeNull() + expect(validateCommand('/help me', mockCommands)).toBeNull() + expect(validateCommand('/search query', mockCommands)).toBeNull() + }) + + it('should return null for incomplete command (empty name)', () => { + expect(validateCommand('/', mockCommands)).toBeNull() + }) + + it('should return error for invalid command', () => { + expect(validateCommand('/invalid', mockCommands)).toBe('/invalid is not a valid command') + expect(validateCommand('/unknown command', mockCommands)).toBe( + '/unknown is not a valid command' + ) + }) + + it('should return null when no commands available', () => { + expect(validateCommand('/help', [])).toBeNull() + }) + }) +}) diff --git a/tests/unit/renderer/stores/commandStore.test.ts b/tests/unit/renderer/stores/commandStore.test.ts new file mode 100644 index 0000000000..cb4b4ec558 --- /dev/null +++ b/tests/unit/renderer/stores/commandStore.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for commandStore + */ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCommandStore } from '../../../../src/renderer/src/stores/commandStore' +import type { AvailableCommand } from '../../../../src/shared/types' + +describe('commandStore', () => { + beforeEach(() => { + // Reset store before each test + useCommandStore.getState().clearCommands() + }) + + describe('setAvailableCommands', () => { + it('should set available commands', () => { + const commands: AvailableCommand[] = [ + { name: 'help', description: 'Show help' }, + { name: 'search', description: 'Search the codebase' } + ] + + useCommandStore.getState().setAvailableCommands(commands) + + expect(useCommandStore.getState().availableCommands).toEqual(commands) + }) + + it('should replace existing commands', () => { + const initialCommands: AvailableCommand[] = [{ name: 'help', description: 'Show help' }] + const newCommands: AvailableCommand[] = [ + { name: 'search', description: 'Search the codebase' }, + { name: 'create', description: 'Create something' } + ] + + useCommandStore.getState().setAvailableCommands(initialCommands) + useCommandStore.getState().setAvailableCommands(newCommands) + + expect(useCommandStore.getState().availableCommands).toEqual(newCommands) + }) + }) + + describe('clearCommands', () => { + it('should clear all commands', () => { + const commands: AvailableCommand[] = [{ name: 'help', description: 'Show help' }] + + useCommandStore.getState().setAvailableCommands(commands) + useCommandStore.getState().clearCommands() + + expect(useCommandStore.getState().availableCommands).toEqual([]) + }) + }) + + describe('initial state', () => { + it('should start with empty commands', () => { + expect(useCommandStore.getState().availableCommands).toEqual([]) + }) + }) +})