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:
yushen
2026-01-20 17:02:47 +08:00
parent 589e67c66f
commit 3d95e19dd3
15 changed files with 474 additions and 10 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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[]
}
/**

View File

@@ -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) => {

View File

@@ -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),

View File

@@ -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}

View 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>
)
}

View File

@@ -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(

View 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: [] })
}
}))

View 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
}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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'

View 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()
})
})
})

View 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([])
})
})
})