fix: make desktop slash commands behave like the CLI

Desktop had no reliable handling for common local slash commands, so
commands like /clear, /help, /context, /cost, and /compact either looked
unresponsive or lost their CLI-specific side effects. This routes desktop
commands by behavior: local panels stay local, stateful clear resets the
session transcript, and CLI-local outputs are rendered in chat.

Constraint: Preserve existing CLI semantics for prompt, local, local-jsx, and compact slash commands
Rejected: Send every slash command through the normal chat path | local commands need desktop UI or session state side effects
Confidence: high
Scope-risk: moderate
Directive: Keep hidden aliases such as /plugins out of the visible command list unless they become canonical CLI commands
Tested: cd desktop && bun run test src/components/chat/composerUtils.test.ts src/stores/chatStore.test.ts --run
Tested: cd desktop && bun run test src/__tests__/pages.test.tsx --run -t "ActiveSession routes /plugin|ActiveSession routes /help|EmptySession slash picker includes dynamic skills"
Tested: bun test src/server/__tests__/conversations.test.ts
Tested: cd desktop && bun run lint
Tested: agent-browser E2E for /help, /plugin, /plugins, /cost, /clear, /context, /compact, /mcp, and normal text input
Not-tested: Root tsc project because it currently includes generated Tauri target assets and extracted native files
This commit is contained in:
程序员阿江(Relakkes) 2026-04-27 18:27:51 +08:00
parent 08e00e7f7c
commit caa164b62c
11 changed files with 461 additions and 17 deletions

View File

@ -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(<ActiveSession />)
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(<ActiveSession />)
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(<ActiveSession />)
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(<ActiveSession />)
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(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')

View File

@ -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) {
<LocalSlashCommandPanel
command={localSlashPanel}
cwd={resolvedWorkDir}
commands={allSlashCommands}
onClose={() => setLocalSlashPanel(null)}
/>
</div>

View File

@ -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 <McpPanel cwd={cwd} onClose={onClose} />
return <SkillsPanel cwd={cwd} onClose={onClose} />
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<string, SlashCommandOption>()
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) => (
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-4 py-3 first:border-t-0">
<div className="shrink-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{command.description}</div>
</div>
)
return (
<PanelShell
title="Slash commands"
subtitle="Run agent workflows, inspect session state, or open desktop panels."
onClose={onClose}
>
<div className="space-y-4">
{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 (
<section key={group.title}>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{group.title}</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{entries.map(renderCommand)}
</div>
</section>
)
})}
{otherCommands.length > 0 && (
<section>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">More</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{otherCommands.map(renderCommand)}
</div>
</section>
)}
</div>
</PanelShell>
)
}
export function LocalSlashCommandPanel({ command, cwd, commands, onClose }: Props) {
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
return <HelpPanel commands={commands} onClose={onClose} />
}

View File

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

View File

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

View File

@ -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() {
<LocalSlashCommandPanel
command={localSlashPanel}
cwd={workDir || undefined}
commands={allSlashCommands}
onClose={() => setLocalSlashPanel(null)}
/>
</div>

View File

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

View File

@ -768,6 +768,44 @@ export const useChatStore = create<ChatStore>((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<string, unknown>
const toolUseId =

View File

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

View File

@ -789,6 +789,50 @@ export class SessionService {
await fs.unlink(found.filePath)
}
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
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 }

View File

@ -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<WebSocketData>,
) {
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<WebSocketData>) {
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<WebSocketData>, 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]*?)</${tag}>`))
return match?.[1]?.trim() ?? null
}
function rebindSessionOutput(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,