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