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,