diff --git a/README.md b/README.md index 7c1c1bb5..4c59857d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ CCManager supports multiple AI coding assistants with tailored state detection f | Cline CLI | `cline` | [github.com/cline/cline](https://github.com/cline/cline) | | OpenCode | `opencode` | [opencode.ai/docs](https://opencode.ai/docs) | | Kimi CLI | `kimi` | [kimi-cli.com](https://www.kimi-cli.com/en/) | +| MiniMax Code (MCode) | `mcode` | [npmjs.com/package/@minimax-ai/code](https://www.npmjs.com/package/@minimax-ai/code) | Each assistant has its own state detection strategy to properly track: - **Idle**: Ready for new input diff --git a/src/components/ConfigureCommand.tsx b/src/components/ConfigureCommand.tsx index 9231fc16..713070f9 100644 --- a/src/components/ConfigureCommand.tsx +++ b/src/components/ConfigureCommand.tsx @@ -32,6 +32,7 @@ const createStrategyItems = (): { cline: {label: 'Cline', value: 'cline'}, opencode: {label: 'OpenCode', value: 'opencode'}, kimi: {label: 'Kimi', value: 'kimi'}, + mcode: {label: 'MiniMax Code', value: 'mcode'}, }; return Object.values(strategies); @@ -50,6 +51,7 @@ const DEFAULT_COMMANDS: Record = { cline: 'cline', opencode: 'opencode', kimi: 'kimi', + mcode: 'mcode', }; interface ConfigureCommandProps { @@ -81,6 +83,8 @@ const formatDetectionStrategy = (strategy: string | undefined): string => { return 'OpenCode'; case 'kimi': return 'Kimi'; + case 'mcode': + return 'MiniMax Code'; default: return 'Claude'; } diff --git a/src/services/stateDetector/index.ts b/src/services/stateDetector/index.ts index ac63a08e..c6dfe267 100644 --- a/src/services/stateDetector/index.ts +++ b/src/services/stateDetector/index.ts @@ -8,6 +8,7 @@ import {GitHubCopilotStateDetector} from './github-copilot.js'; import {ClineStateDetector} from './cline.js'; import {OpenCodeStateDetector} from './opencode.js'; import {KimiStateDetector} from './kimi.js'; +import {MCodeStateDetector} from './mcode.js'; export function createStateDetector( strategy: StateDetectionStrategy = 'claude', @@ -29,6 +30,8 @@ export function createStateDetector( return new OpenCodeStateDetector(); case 'kimi': return new KimiStateDetector(); + case 'mcode': + return new MCodeStateDetector(); default: return new ClaudeStateDetector(); } diff --git a/src/services/stateDetector/mcode.test.ts b/src/services/stateDetector/mcode.test.ts new file mode 100644 index 00000000..bcd4d8c1 --- /dev/null +++ b/src/services/stateDetector/mcode.test.ts @@ -0,0 +1,72 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type {Terminal} from '../../types/index.js'; +import {createStateDetector} from './index.js'; +import {MCodeStateDetector} from './mcode.js'; +import {createMockTerminal} from './testUtils.js'; + +describe('MCodeStateDetector', () => { + let detector: MCodeStateDetector; + let terminal: Terminal; + + beforeEach(() => { + detector = new MCodeStateDetector(); + }); + + it('detects the published MCode ready footer as idle', () => { + terminal = createMockTerminal([ + '╭─ v0.1.2 ● Ready ─╮', + '│ Start · @ file · / autocomplete │', + '╰─────────────────────────────────────────╯', + ]); + + expect(detector.detectState(terminal, 'busy')).toBe('idle'); + }); + + it('is selected by the mcode strategy', () => { + expect(createStateDetector('mcode')).toBeInstanceOf(MCodeStateDetector); + }); + + it('detects an active runtime turn as busy', () => { + terminal = createMockTerminal([ + 'session: mvs_abc | turn: turn_123 running 3s | local-runtime', + ]); + + expect(detector.detectState(terminal, 'idle')).toBe('busy'); + }); + + it('detects the compact running status as busy', () => { + terminal = createMockTerminal(['session: mvs_abc | turn: running']); + + expect(detector.detectState(terminal, 'idle')).toBe('busy'); + }); + + it.each([ + 'permission: pending', + '[permission] permission.ask', + 'ask_user: pending', + '[ask_user] questionnaire.ask', + ])('detects %s as waiting for input', marker => { + terminal = createMockTerminal([ + 'session: mvs_abc | turn: turn_123 running 3s', + marker, + ]); + + expect(detector.detectState(terminal, 'busy')).toBe('waiting_input'); + }); + + it('does not mistake historical thinking text for an active turn', () => { + terminal = createMockTerminal([ + 'Assistant: Thinking through the previous request...', + '╭─ v0.1.2 ● Ready ─╮', + ]); + + expect(detector.detectState(terminal, 'busy')).toBe('idle'); + }); + + it('reports no background tasks or team members', () => { + terminal = createMockTerminal([]); + + expect(detector.detectBackgroundTask(terminal)).toBe(0); + expect(detector.detectTeamMembers(terminal)).toBe(0); + }); +}); diff --git a/src/services/stateDetector/mcode.ts b/src/services/stateDetector/mcode.ts new file mode 100644 index 00000000..88162e92 --- /dev/null +++ b/src/services/stateDetector/mcode.ts @@ -0,0 +1,43 @@ +import {SessionState, Terminal} from '../../types/index.js'; +import {BaseStateDetector} from './base.js'; + +/** + * State detector for MiniMax Code (MCode). + * Command: mcode + * Installation: npm install -g @minimax-ai/code + */ +export class MCodeStateDetector extends BaseStateDetector { + detectState(terminal: Terminal, _currentState: SessionState): SessionState { + const content = this.getTerminalContent(terminal, 30); + const lowerContent = content.toLowerCase(); + + // MCode exposes pending interactive decisions in its runtime status and + // renders the event marker in the transcript. These take precedence over + // an active turn because the user must act before that turn can continue. + if ( + lowerContent.includes('permission: pending') || + lowerContent.includes('[permission] permission.ask') || + lowerContent.includes('ask_user: pending') || + lowerContent.includes('[ask_user] questionnaire.ask') + ) { + return 'waiting_input'; + } + + // Current releases include a turn id; the compact fallback is emitted + // while a turn is starting. Match the status field rather than ordinary + // assistant text so historical words such as "thinking" cannot look busy. + if (/\bturn:\s+(?:\S+\s+)?running\b/i.test(content)) { + return 'busy'; + } + + return 'idle'; + } + + detectBackgroundTask(_terminal: Terminal): number { + return 0; + } + + detectTeamMembers(_terminal: Terminal): number { + return 0; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index f1a7e5cb..67622aa6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,7 +23,8 @@ export type StateDetectionStrategy = | 'github-copilot' | 'cline' | 'opencode' - | 'kimi'; + | 'kimi' + | 'mcode'; export interface Worktree { path: string; diff --git a/src/utils/presetPrompt.test.ts b/src/utils/presetPrompt.test.ts index 5c2a2ac6..14c44bf5 100644 --- a/src/utils/presetPrompt.test.ts +++ b/src/utils/presetPrompt.test.ts @@ -102,6 +102,18 @@ describe('presetPrompt', () => { }); }); + it('uses the final argument for MCode presets', () => { + expect( + preparePresetLaunch( + {command: 'mcode', args: [], detectionStrategy: 'mcode'}, + 'fix the failing tests', + ), + ).toEqual({ + args: ['fix the failing tests'], + method: 'final-arg', + }); + }); + describe('describePromptInjection', () => { it('describes final-arg for claude', () => { expect( @@ -174,6 +186,15 @@ describe('presetPrompt', () => { }), ).toContain('-p'); }); + + it('describes final-arg for MCode', () => { + expect( + describePromptInjection({ + command: 'mcode', + detectionStrategy: 'mcode', + }), + ).toContain('final command argument'); + }); }); describe('getPromptInjectionMethod', () => { @@ -249,6 +270,15 @@ describe('presetPrompt', () => { ).toBe('flag'); }); + it('returns final-arg for MCode', () => { + expect( + getPromptInjectionMethod({ + command: 'mcode', + detectionStrategy: 'mcode', + }), + ).toBe('final-arg'); + }); + it('falls back to claude strategy when detectionStrategy is not set', () => { expect(getPromptInjectionMethod({command: 'claude'})).toBe('final-arg'); }); diff --git a/src/utils/presetPrompt.ts b/src/utils/presetPrompt.ts index 4bd23bdb..5cbfb0dc 100644 --- a/src/utils/presetPrompt.ts +++ b/src/utils/presetPrompt.ts @@ -43,7 +43,8 @@ export const getPromptInjectionMethod = ( strategy === 'claude' || strategy === 'codex' || strategy === 'cursor' || - strategy === 'cline' + strategy === 'cline' || + strategy === 'mcode' ) { return 'final-arg'; }