diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index f54f7dc1..a711ca3a 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -474,7 +474,13 @@ describe('Content-only pages render without errors', () => { tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', - slashCommands: [{ name: 'cost', description: 'Show token usage and costs' }], + slashCommands: [ + { name: 'cost', description: 'Show token usage and costs' }, + ...Array.from({ length: 14 }, (_, index) => ({ + name: `extra-${index + 1}`, + description: `Extra command ${index + 1}`, + })), + ], agentTaskNotifications: {}, elapsedTimer: null, }, @@ -492,6 +498,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('Slash commands')).toBeInTheDocument() expect(screen.getByText('/clear')).toBeInTheDocument() expect(screen.getByText('/cost')).toBeInTheDocument() + expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument() useTabStore.setState({ tabs: [], activeTabId: null }) useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index 987246bf..44581ce2 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -319,6 +319,10 @@ function HelpPanel({ const otherCommands = (commands ?? []) .filter((command) => !groupedNames.has(command.name)) .slice(0, 12) + const hiddenOtherCommandCount = Math.max( + 0, + (commands ?? []).filter((command) => !groupedNames.has(command.name)).length - otherCommands.length, + ) const renderCommand = (command: SlashCommandOption) => (
@@ -355,6 +359,11 @@ function HelpPanel({
{otherCommands.map(renderCommand)}
+ {hiddenOtherCommandCount > 0 && ( +

+ {hiddenOtherCommandCount} more commands available. Type / to search the full command list. +

+ )} )}
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 9ef316df..c8e975e4 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -224,7 +224,7 @@ describe('chatStore history mapping', () => { tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', - slashCommands: [], + slashCommands: [{ name: 'old-command', description: 'Old command' }], agentTaskNotifications: {}, elapsedTimer: null, }, @@ -519,6 +519,7 @@ describe('chatStore history mapping', () => { expect(session?.streamingText).toBe('') expect(session?.chatState).toBe('idle') expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 }) + expect(session?.slashCommands).toEqual([]) expect(clearTasksMock).toHaveBeenCalled() }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 67d720d7..663e4061 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -785,6 +785,7 @@ export const useChatStore = create((set, get) => ({ elapsedSeconds: 0, statusVerb: '', tokenUsage: { input_tokens: 0, output_tokens: 0 }, + slashCommands: [], })) useCLITaskStore.getState().clearTasks() useSessionStore.getState().updateSessionTitle(sessionId, 'New Session') diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index c7b837d0..58a5bf4d 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -235,7 +235,7 @@ describe('WebSocket Chat Integration', () => { } } - async function runTurn(sessionId: string, content: string): Promise { + async function runTurn(sessionId: string, content: string, allowError = false): Promise { const messages: any[] = [] const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) @@ -254,7 +254,11 @@ describe('WebSocket Chat Integration', () => { if (msg.type === 'error') { clearTimeout(timeout) ws.close() - reject(new Error(msg.message)) + if (allowError) { + resolve() + } else { + reject(new Error(msg.message)) + } } if (msg.type === 'message_complete') { clearTimeout(timeout) @@ -544,6 +548,33 @@ describe('WebSocket Chat Integration', () => { expect(body.messages).toEqual([]) }) + it('should reject /clear arguments without clearing the desktop session', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + await runTurn(sessionId, 'message before invalid clear') + + const clearTurn = await runTurn(sessionId, '/clear please keep this', true) + expect( + clearTurn.some( + (m) => m.type === 'error' && m.code === 'INVALID_SLASH_COMMAND_ARGS', + ), + ).toBe(true) + expect( + clearTurn.some( + (m) => m.type === 'system_notification' && m.subtype === 'session_cleared', + ), + ).toBe(false) + + const nextTurn = await runTurn(sessionId, 'message after invalid clear') + expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true) + }) + it('should prewarm the CLI before the first user turn and reuse that process', async () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 58627bd4..3a795819 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -219,7 +219,18 @@ async function handleUserMessage( sessionStopRequested.delete(sessionId) clearPrewarmState(sessionId) - if (getDesktopSlashCommandName(message.content) === 'clear') { + const desktopSlashCommand = getDesktopSlashCommand(message.content) + if (desktopSlashCommand?.commandName === 'clear' && desktopSlashCommand.args.trim()) { + sendMessage(ws, { + type: 'error', + message: 'The /clear command does not accept arguments.', + code: 'INVALID_SLASH_COMMAND_ARGS', + }) + sendMessage(ws, { type: 'status', state: 'idle' }) + return + } + + if (desktopSlashCommand?.commandName === 'clear') { await handleDesktopClearCommand(ws) return } @@ -1080,7 +1091,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { return [{ type: 'system_notification', subtype: 'compact_boundary', - message: 'Context compacted', + message: getCompactBoundaryMessage(cliMsg), data: cliMsg.compact_metadata ?? cliMsg, }] } @@ -1107,9 +1118,10 @@ function sendError(ws: ServerWebSocket, message: string, code: st sendMessage(ws, { type: 'error', message, code }) } -function getDesktopSlashCommandName(content: string): string | null { +function getDesktopSlashCommand(content: string): ReturnType { const parsed = parseSlashCommand(content.trim()) - return parsed?.commandName ?? null + if (!parsed || parsed.isMcp) return null + return parsed } function extractLocalCommandOutput(content: unknown): string | null { @@ -1139,6 +1151,16 @@ function extractTaggedContent(raw: string, tag: string): string | null { return match?.[1]?.trim() ?? null } +function getCompactBoundaryMessage(cliMsg: any): string { + const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : '' + if (message) return message + + const content = typeof cliMsg?.content === 'string' ? cliMsg.content.trim() : '' + if (content) return content + + return 'Context compacted' +} + function rebindSessionOutput( sessionId: string, ws: ServerWebSocket,