diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 38990c7f..f54f7dc1 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -85,8 +85,10 @@ describe('Content-only pages render without errors', () => { expect(await screen.findByText('/lark-mail')).toBeInTheDocument() expect(screen.getByText('/mcp')).toBeInTheDocument() expect(screen.getByText('/skills')).toBeInTheDocument() + expect(screen.getByText('/help')).toBeInTheDocument() expect(screen.getByText('/plugin')).toBeInTheDocument() - expect(screen.getByText('/plugins')).toBeInTheDocument() + expect(screen.getByText('/context')).toBeInTheDocument() + expect(screen.queryByText('/plugins')).not.toBeInTheDocument() expect(screen.queryByText('/internal-only')).not.toBeInTheDocument() }) @@ -294,7 +296,7 @@ describe('Content-only pages render without errors', () => { render() - const textarea = screen.getByPlaceholderText('Ask anything...') + const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: '/mcp', selectionStart: 4 } }) fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) @@ -366,7 +368,7 @@ describe('Content-only pages render without errors', () => { render() - const textarea = screen.getByPlaceholderText('Ask anything...') + const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } }) fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) @@ -424,7 +426,7 @@ describe('Content-only pages render without errors', () => { render() - const textarea = screen.getByPlaceholderText('Ask anything...') + const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } }) fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) @@ -437,6 +439,65 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession routes /help to the local command panel', () => { + const SESSION_ID = 'help-panel-session' + const sendMessage = vi.fn() + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [{ name: 'cost', description: 'Show token usage and costs' }], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: '/help', selectionStart: 5 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(screen.getByText('Slash commands')).toBeInTheDocument() + expect(screen.getByText('/clear')).toBeInTheDocument() + expect(screen.getByText('/cost')).toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('AgentTeams renders team strip and members', () => { const { container } = render() expect(container.innerHTML).toContain('Architect') diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 3b29772d..2686d306 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -197,15 +197,20 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { return () => document.removeEventListener('mousedown', handleClick) }, [fileSearchOpen]) + const allSlashCommands = useMemo( + () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), + [slashCommands], + ) + const filteredCommands = useMemo(() => { - const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS) + const source = allSlashCommands if (!slashFilter) return source const lower = slashFilter.toLowerCase() return source.filter((command) => ( command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower) )) - }, [slashCommands, slashFilter]) + }, [allSlashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() @@ -541,6 +546,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { setLocalSlashPanel(null)} /> diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index 64c23cac..987246bf 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -8,12 +8,14 @@ import { useMcpStore } from '../../stores/mcpStore' import { useSkillStore } from '../../stores/skillStore' import type { McpServerRecord } from '../../types/mcp' import type { SkillMeta } from '../../types/skill' +import type { SlashCommandOption } from './composerUtils' -export type LocalSlashCommandName = 'mcp' | 'skills' +export type LocalSlashCommandName = 'mcp' | 'skills' | 'help' type Props = { command: LocalSlashCommandName cwd?: string + commands?: SlashCommandOption[] onClose: () => void } @@ -283,7 +285,85 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { ) } -export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) { - if (command === 'mcp') return - return +const COMMAND_GROUPS = [ + { + title: 'Context', + names: ['clear', 'compact', 'context', 'cost'], + }, + { + title: 'Project', + names: ['init', 'review', 'commit', 'pr'], + }, + { + title: 'Desktop', + names: ['mcp', 'skills', 'plugin', 'help'], + }, +] + +function HelpPanel({ + commands, + onClose, +}: { + commands?: SlashCommandOption[] + onClose: () => void +}) { + const commandMap = useMemo(() => { + const map = new Map() + for (const command of commands ?? []) { + map.set(command.name, command) + } + return map + }, [commands]) + + const groupedNames = new Set(COMMAND_GROUPS.flatMap((group) => group.names)) + const otherCommands = (commands ?? []) + .filter((command) => !groupedNames.has(command.name)) + .slice(0, 12) + + const renderCommand = (command: SlashCommandOption) => ( +
+
/{command.name}
+
{command.description}
+
+ ) + + return ( + +
+ {COMMAND_GROUPS.map((group) => { + const entries = group.names + .map((name) => commandMap.get(name)) + .filter((command): command is SlashCommandOption => Boolean(command)) + if (entries.length === 0) return null + return ( +
+
{group.title}
+
+ {entries.map(renderCommand)} +
+
+ ) + })} + + {otherCommands.length > 0 && ( +
+
More
+
+ {otherCommands.map(renderCommand)} +
+
+ )} +
+
+ ) +} + +export function LocalSlashCommandPanel({ command, cwd, commands, onClose }: Props) { + if (command === 'mcp') return + if (command === 'skills') return + return } diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 871872dd..a3561a56 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -4,6 +4,7 @@ import { insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, + resolveSlashUiAction, } from './composerUtils' describe('composerUtils', () => { @@ -35,8 +36,9 @@ describe('composerUtils', () => { ]), ).toEqual( expect.arrayContaining([ - { name: 'help', description: 'Show available commands' }, + { name: 'help', description: 'Show available desktop and agent commands' }, { name: 'clear', description: 'Clear conversation history' }, + { name: 'context', description: 'Show current context usage' }, ]), ) }) @@ -52,4 +54,10 @@ describe('composerUtils', () => { ]), ) }) + + it('resolves hidden settings aliases without displaying duplicate fallback rows', () => { + expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) + expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin') + expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins') + }) }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 0c2c8897..57e4faa3 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -3,11 +3,15 @@ import type { SettingsTab } from '../../stores/uiStore' export const PANEL_SLASH_COMMANDS = [ { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, { name: 'skills', description: 'Browse user-invocable skills for the current chat context' }, + { name: 'help', description: 'Show available desktop and agent commands' }, ] as const export const SETTINGS_SLASH_COMMANDS = [ { name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, - { name: 'plugins', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, +] as const + +export const SLASH_COMMAND_ALIASES = [ + { name: 'plugins', target: 'plugin' }, ] as const export const FALLBACK_SLASH_COMMANDS = [ @@ -15,13 +19,13 @@ export const FALLBACK_SLASH_COMMANDS = [ ...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })), { name: 'compact', description: 'Compact conversation context' }, { name: 'clear', description: 'Clear conversation history' }, - { name: 'help', description: 'Show available commands' }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, { name: 'pr', description: 'Create a pull request' }, { name: 'init', description: 'Initialize project CLAUDE.md' }, { name: 'bug', description: 'Report a bug' }, { name: 'config', description: 'Open configuration' }, + { name: 'context', description: 'Show current context usage' }, { name: 'cost', description: 'Show token usage and costs' }, { name: 'doctor', description: 'Diagnose installation issues' }, { name: 'login', description: 'Switch Anthropic accounts' }, @@ -50,12 +54,13 @@ export type SlashUiAction = } export function resolveSlashUiAction(value: string): SlashUiAction | null { - const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === value) + const normalizedValue = SLASH_COMMAND_ALIASES.find((alias) => alias.name === value)?.target ?? value + const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === normalizedValue) if (panelCommand) { return { type: 'panel', command: panelCommand.name } } - const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === value) + const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === normalizedValue) if (settingsCommand) { return { type: 'settings', tab: settingsCommand.tab } } diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index e7a5d77b..fb764f2c 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -153,15 +153,20 @@ export function EmptySession() { } }, [workDir]) + const allSlashCommands = useMemo( + () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), + [slashCommands], + ) + const filteredCommands = useMemo(() => { - const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS) + const source = allSlashCommands if (!slashFilter) return source const lower = slashFilter.toLowerCase() return source.filter((command) => ( command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower) )) - }, [slashCommands, slashFilter]) + }, [allSlashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() @@ -501,6 +506,7 @@ export function EmptySession() { setLocalSlashPanel(null)} /> diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 5130efab..9ef316df 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -481,6 +481,82 @@ describe('chatStore history mapping', () => { }) }) + it('clears local desktop chat state when the server confirms /clear', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [ + { id: 'u1', type: 'user_text', content: '/clear', timestamp: Date.now() }, + { id: 'a1', type: 'assistant_text', content: 'old context', timestamp: Date.now() }, + ], + chatState: 'thinking', + connectionState: 'connected', + streamingText: 'pending', + streamingToolInput: 'tool', + activeToolUseId: 'tool-1', + activeToolName: 'Read', + activeThinkingId: 'thinking-1', + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 12, output_tokens: 34 }, + elapsedSeconds: 5, + statusVerb: 'Thinking', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'session_cleared', + message: 'Conversation cleared', + }) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.messages).toEqual([]) + expect(session?.streamingText).toBe('') + expect(session?.chatState).toBe('idle') + expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 }) + expect(clearTasksMock).toHaveBeenCalled() + }) + + it('renders compact boundary notifications as system messages', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'compact_boundary', + message: 'Context compacted', + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { type: 'system', content: 'Context compacted' }, + ]) + }) + it('flushes the previous assistant draft before starting a new user turn', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 357466c8..67d720d7 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -768,6 +768,44 @@ export const useChatStore = create((set, get) => ({ if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) } + if (msg.subtype === 'session_cleared') { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) clearInterval(session.elapsedTimer) + update(() => ({ + messages: [], + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + chatState: 'idle', + elapsedTimer: null, + elapsedSeconds: 0, + statusVerb: '', + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + })) + useCLITaskStore.getState().clearTasks() + useSessionStore.getState().updateSessionTitle(sessionId, 'New Session') + useTabStore.getState().updateTabTitle(sessionId, 'New Session') + useTabStore.getState().updateTabStatus(sessionId, 'idle') + } + if (msg.subtype === 'compact_boundary') { + update((session) => ({ + messages: [ + ...session.messages, + { + id: nextId(), + type: 'system', + content: typeof msg.message === 'string' && msg.message.trim() + ? msg.message + : 'Context compacted', + timestamp: Date.now(), + }, + ], + })) + } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { const data = msg.data as Record const toolUseId = diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index ad1f04da..c7b837d0 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -518,6 +518,32 @@ describe('WebSocket Chat Integration', () => { expect(secondTurn.some((m) => m.type === 'error')).toBe(false) }) + it('should clear a desktop session without sending /clear to the CLI turn loop', 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 } + + const firstTurn = await runTurn(sessionId, 'message before clear') + expect(firstTurn.some((m) => m.type === 'message_complete')).toBe(true) + + const clearTurn = await runTurn(sessionId, '/clear') + expect( + clearTurn.some( + (m) => m.type === 'system_notification' && m.subtype === 'session_cleared', + ), + ).toBe(true) + expect(clearTurn.some((m) => m.type === 'content_delta')).toBe(false) + + const messagesRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`) + expect(messagesRes.status).toBe(200) + const body = await messagesRes.json() as { messages: unknown[] } + expect(body.messages).toEqual([]) + }) + 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/services/sessionService.ts b/src/server/services/sessionService.ts index 7a6383cd..c656871d 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -789,6 +789,50 @@ export class SessionService { await fs.unlink(found.filePath) } + async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise { + let found = await this.findSessionFile(sessionId) + if (!found && fallbackWorkDir) { + const absWorkDir = path.resolve(fallbackWorkDir) + const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir)) + await fs.mkdir(dirPath, { recursive: true }) + found = { + filePath: path.join(dirPath, `${sessionId}.jsonl`), + projectDir: this.sanitizePath(absWorkDir), + } + } + if (!found) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + const entries = await this.readJsonlFile(found.filePath) + const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd() + const now = new Date().toISOString() + + const initialEntry = { + type: 'file-history-snapshot', + messageId: crypto.randomUUID(), + snapshot: { + messageId: crypto.randomUUID(), + trackedFileBackups: {}, + timestamp: now, + }, + isSnapshotUpdate: false, + } + + const metaEntry = { + type: 'session-meta', + isMeta: true, + workDir, + timestamp: now, + } + + await fs.writeFile( + found.filePath, + `${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`, + 'utf-8', + ) + } + async appendSessionMetadata( sessionId: string, metadata: { workDir: string; customTitle?: string | null } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 6b3f9797..58627bd4 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -18,6 +18,11 @@ import { sessionService } from '../services/sessionService.js' import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' +import { parseSlashCommand } from '../../utils/slashCommandParsing.js' +import { + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, +} from '../../constants/xml.js' const settingsService = new SettingsService() const providerService = new ProviderService() @@ -214,6 +219,11 @@ async function handleUserMessage( sessionStopRequested.delete(sessionId) clearPrewarmState(sessionId) + if (getDesktopSlashCommandName(message.content) === 'clear') { + await handleDesktopClearCommand(ws) + return + } + // Send thinking status sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' }) @@ -292,6 +302,42 @@ async function handleUserMessage( userMessageSent = true } +async function handleDesktopClearCommand( + ws: ServerWebSocket, +) { + const { sessionId } = ws.data + + const workDir = conversationService.getSessionWorkDir(sessionId) + conversationService.stopSession(sessionId) + conversationService.clearOutputCallbacks(sessionId) + sessionSlashCommands.delete(sessionId) + sessionTitleState.delete(sessionId) + cleanupStreamState(sessionId) + + try { + await sessionService.clearSessionTranscript(sessionId, workDir || undefined) + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + sendMessage(ws, { + type: 'error', + message: errMsg, + code: 'SESSION_CLEAR_FAILED', + }) + sendMessage(ws, { type: 'status', state: 'idle' }) + return + } + + sendMessage(ws, { + type: 'system_notification', + subtype: 'session_cleared', + message: 'Conversation cleared', + }) + sendMessage(ws, { + type: 'message_complete', + usage: { input_tokens: 0, output_tokens: 0 }, + }) +} + function handlePrewarmSession(ws: ServerWebSocket) { const { sessionId } = ws.data if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) { @@ -772,6 +818,14 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { // CLI 发送 type:'user' 消息,其中 content 包含 tool_result 块 const messages: ServerMessage[] = [] + const localCommandOutput = extractLocalCommandOutput( + cliMsg.message?.content, + ) + if (localCommandOutput) { + messages.push({ type: 'content_start', blockType: 'text' }) + messages.push({ type: 'content_delta', text: localCommandOutput }) + } + if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) { for (const block of cliMsg.message.content) { if (block.type === 'tool_result') { @@ -1022,6 +1076,14 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { data: cliMsg, }] } + if (subtype === 'compact_boundary') { + return [{ + type: 'system_notification', + subtype: 'compact_boundary', + message: 'Context compacted', + data: cliMsg.compact_metadata ?? cliMsg, + }] + } // 其他 system 消息 return [] } @@ -1045,6 +1107,38 @@ function sendError(ws: ServerWebSocket, message: string, code: st sendMessage(ws, { type: 'error', message, code }) } +function getDesktopSlashCommandName(content: string): string | null { + const parsed = parseSlashCommand(content.trim()) + return parsed?.commandName ?? null +} + +function extractLocalCommandOutput(content: unknown): string | null { + const raw = typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }) + .join('\n') + : '' + + if (!raw) return null + + const stdout = extractTaggedContent(raw, LOCAL_COMMAND_STDOUT_TAG) + if (stdout !== null) return stdout + + const stderr = extractTaggedContent(raw, LOCAL_COMMAND_STDERR_TAG) + return stderr +} + +function extractTaggedContent(raw: string, tag: string): string | null { + const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)`)) + return match?.[1]?.trim() ?? null +} + function rebindSessionOutput( sessionId: string, ws: ServerWebSocket,