mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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<ImageContentItem[]>([])
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false)
|
||||
const [commandMenuIndex, setCommandMenuIndex] = useState(0)
|
||||
const [commandError, setCommandError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const inputContainerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="pb-2">
|
||||
{directoryExists === false && <DirectoryWarningBanner onDeleteSession={onDeleteSession} />}
|
||||
<div>
|
||||
<div ref={inputContainerRef} className="relative">
|
||||
{/* Slash command autocomplete menu */}
|
||||
<SlashCommandMenu
|
||||
commands={availableCommands}
|
||||
filter={commandFilter}
|
||||
selectedIndex={commandMenuIndex}
|
||||
onSelect={handleCommandSelect}
|
||||
onIndexChange={setCommandMenuIndex}
|
||||
onClose={() => setShowCommandMenu(false)}
|
||||
visible={showCommandMenu}
|
||||
/>
|
||||
|
||||
<div className="bg-card transition-colors duration-200 rounded-xl p-3 border border-border">
|
||||
{/* 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 && <div className="px-1 py-1 text-xs text-destructive">{commandError}</div>}
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
141
src/renderer/src/components/SlashCommandMenu.tsx
Normal file
141
src/renderer/src/components/SlashCommandMenu.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Slash command autocomplete menu
|
||||
*
|
||||
* Displays a list of available commands when the user types "/" in the message input.
|
||||
* Supports keyboard navigation and command selection.
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import type { AvailableCommand } from '../../../shared/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Re-export utility functions for convenience
|
||||
export { parseSlashCommand, validateCommand } from '../utils/slashCommand'
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
/** Available commands to display */
|
||||
commands: AvailableCommand[]
|
||||
/** Current filter string (after the "/") */
|
||||
filter: string
|
||||
/** Currently selected index */
|
||||
selectedIndex: number
|
||||
/** Callback when an index is selected */
|
||||
onSelect: (command: AvailableCommand) => 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<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(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 (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute bottom-full left-0 mb-2 w-full max-w-md bg-popover border border-border rounded-lg shadow-lg overflow-hidden z-50"
|
||||
>
|
||||
<div className="max-h-[240px] overflow-y-auto py-1">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<button
|
||||
key={command.name}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
itemRefs.current.set(index, el)
|
||||
} else {
|
||||
itemRefs.current.delete(index)
|
||||
}
|
||||
}}
|
||||
onClick={() => onSelect(command)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 flex flex-col gap-0.5 transition-colors',
|
||||
index === clampedIndex ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">/{command.name}</span>
|
||||
{command.input && (
|
||||
<span className="text-xs text-muted-foreground">{'{argument}'}</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{command.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof setAvailableCommands>[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(
|
||||
|
||||
32
src/renderer/src/stores/commandStore.ts
Normal file
32
src/renderer/src/stores/commandStore.ts
Normal file
@@ -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<CommandStore>((set) => ({
|
||||
availableCommands: [],
|
||||
|
||||
setAvailableCommands: (commands) => {
|
||||
set({ availableCommands: commands })
|
||||
},
|
||||
|
||||
clearCommands: () => {
|
||||
set({ availableCommands: [] })
|
||||
}
|
||||
}))
|
||||
41
src/renderer/src/utils/slashCommand.ts
Normal file
41
src/renderer/src/utils/slashCommand.ts
Normal file
@@ -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
|
||||
}
|
||||
6
src/shared/electron-api.d.ts
vendored
6
src/shared/electron-api.d.ts
vendored
@@ -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<void>
|
||||
setSessionModel(sessionId: string, modelId: ModelId): Promise<void>
|
||||
|
||||
// Slash commands
|
||||
getSessionCommands(sessionId: string): Promise<AvailableCommand[]>
|
||||
|
||||
// Configuration
|
||||
getConfig(): Promise<AppConfig>
|
||||
updateConfig(config: Partial<AppConfig>): Promise<AppConfig>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
71
tests/unit/renderer/components/SlashCommandMenu.test.ts
Normal file
71
tests/unit/renderer/components/SlashCommandMenu.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
56
tests/unit/renderer/stores/commandStore.test.ts
Normal file
56
tests/unit/renderer/stores/commandStore.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user