diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 32b3d80ed..0da3dc873 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -317,6 +317,28 @@ export async function runClaude(options: StartOptions = {}): Promise { return; } + if (specialCommand.type === 'model') { + logger.debug('[start] Detected /model command'); + if (specialCommand.model == null) { + // /model with no arg: show current model + const modelName = currentModel ?? 'auto'; + session.sendSessionEvent({ type: 'message', message: `Claude model: ${modelName}` }); + } else { + // /model or /model auto: set model + const newModel = specialCommand.model === 'auto' ? null : specialCommand.model; + currentModel = normalizeClaudeSessionModel(newModel); + currentSessionRef.current?.setModel(currentModel); + currentSessionRef.current?.pushKeepAlive(); + const modelName = currentModel ?? 'auto'; + session.sendSessionEvent({ type: 'message', message: `Claude model set to ${modelName}` }); + logger.debug(`[start] /model command: model changed to ${modelName}`); + } + if (localId) { + session.emitMessagesConsumed([localId]); + } + return; + } + // Push with resolved permission mode, model, system prompts, and tools const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode ?? 'default', diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 2cafb3441..5213919bf 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -30,6 +30,7 @@ const BUILTIN_COMMANDS: Record = { { name: 'compact', description: 'Compact conversation context', source: 'builtin' }, { name: 'context', description: 'Show context information', source: 'builtin' }, { name: 'cost', description: 'Show session cost', source: 'builtin' }, + { name: 'model', description: 'Show or set Claude model, e.g. /model claude-opus-4-7', source: 'builtin' }, { name: 'plan', description: 'Toggle plan mode', source: 'builtin' }, ], codex: [ diff --git a/cli/src/parsers/specialCommands.test.ts b/cli/src/parsers/specialCommands.test.ts index c38b1997f..b66b36bf7 100644 --- a/cli/src/parsers/specialCommands.test.ts +++ b/cli/src/parsers/specialCommands.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseCompact, parseClear, parsePlan, parseSpecialCommand } from './specialCommands'; +import { parseCompact, parseClear, parsePlan, parseModel, parseSpecialCommand } from './specialCommands'; describe('parseCompact', () => { it('should parse /compact command with argument', () => { @@ -92,6 +92,33 @@ describe('parsePlan', () => { }); }); +describe('parseModel', () => { + it('should parse /model without argument', () => { + const result = parseModel('/model'); + expect(result).toEqual({ isModel: true, model: null }); + }); + + it('should parse /model with a model name', () => { + const result = parseModel('/model claude-opus-4-7'); + expect(result).toEqual({ isModel: true, model: 'claude-opus-4-7' }); + }); + + it('should parse /model auto', () => { + const result = parseModel('/model auto'); + expect(result).toEqual({ isModel: true, model: 'auto' }); + }); + + it('should not parse /model with multiple arguments', () => { + const result = parseModel('/model foo bar'); + expect(result.isModel).toBe(false); + }); + + it('should not parse partial matches', () => { + expect(parseModel('/modeler test').isModel).toBe(false); + expect(parseModel('please /model this').isModel).toBe(false); + }); +}); + describe('parseSpecialCommand', () => { it('should detect compact command', () => { const result = parseSpecialCommand('/compact optimize'); @@ -112,6 +139,18 @@ describe('parseSpecialCommand', () => { expect(result.prompt).toBe('帮我规划五一行程'); }); + it('should detect model command without argument', () => { + const result = parseSpecialCommand('/model'); + expect(result.type).toBe('model'); + expect(result.model).toBeNull(); + }); + + it('should detect model command with a model name', () => { + const result = parseSpecialCommand('/model claude-sonnet-4-6'); + expect(result.type).toBe('model'); + expect(result.model).toBe('claude-sonnet-4-6'); + }); + it('should return null for regular messages', () => { const result = parseSpecialCommand('hello world'); expect(result.type).toBeNull(); @@ -123,11 +162,13 @@ describe('parseSpecialCommand', () => { expect(parseSpecialCommand(' /compact test ').type).toBe('compact'); expect(parseSpecialCommand(' /clear ').type).toBe('clear'); expect(parseSpecialCommand(' /plan test ').type).toBe('plan'); - + expect(parseSpecialCommand(' /model claude-opus-4-7 ').type).toBe('model'); + // Test partial matches should not trigger expect(parseSpecialCommand('some /compact text').type).toBeNull(); expect(parseSpecialCommand('/compactor').type).toBeNull(); expect(parseSpecialCommand('/clearing').type).toBeNull(); expect(parseSpecialCommand('/planner').type).toBeNull(); + expect(parseSpecialCommand('/modeler').type).toBeNull(); }); }); diff --git a/cli/src/parsers/specialCommands.ts b/cli/src/parsers/specialCommands.ts index a9ebcac7f..ad2976caa 100644 --- a/cli/src/parsers/specialCommands.ts +++ b/cli/src/parsers/specialCommands.ts @@ -17,11 +17,17 @@ export interface PlanCommandResult { prompt?: string; } +export interface ModelCommandResult { + isModel: boolean; + model: string | null; +} + export interface SpecialCommandResult { - type: 'compact' | 'clear' | 'plan' | null; + type: 'compact' | 'clear' | 'plan' | 'model' | null; originalMessage?: string; mode?: 'plan' | 'default'; prompt?: string; + model?: string | null; } /** @@ -109,6 +115,22 @@ export function parsePlan(message: string): PlanCommandResult { }; } +/** + * Parse /model command + * - /model: show current model + * - /model : set model to + * - /model auto: reset to default model + */ +export function parseModel(message: string): ModelCommandResult { + const trimmed = message.trim(); + const match = /^\/model(?:\s+(\S+))?$/i.exec(trimmed); + if (!match) { + return { isModel: false, model: null }; + } + const arg = match[1]?.trim() ?? null; + return { isModel: true, model: arg }; +} + /** * Unified parser for special commands * Returns the type of command and original message if applicable @@ -121,7 +143,7 @@ export function parseSpecialCommand(message: string): SpecialCommandResult { originalMessage: compactResult.originalMessage }; } - + const clearResult = parseClear(message); if (clearResult.isClear) { return { @@ -138,7 +160,15 @@ export function parseSpecialCommand(message: string): SpecialCommandResult { originalMessage: message.trim() }; } - + + const modelResult = parseModel(message); + if (modelResult.isModel) { + return { + type: 'model', + model: modelResult.model + }; + } + return { type: null };