qwen-code/packages/cli/src/ui/commands/insightCommand.ts
顾盼 a82d766727
refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1) (#3283)
* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)

## Summary

Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.

## Key changes

### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
  'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
  supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
  examples (all optional, backward-compatible)

### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
  (explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests

### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)

### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use

### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
  init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands

### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
  modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
  commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true

### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.

### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
  capability filtering is now self-contained in CommandService

* fix test ci

* fix memory command

* fix: pass 'non_interactive' mode explicitly to getAvailableCommands

- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
  calling getAvailableCommands without specifying mode, causing it to default
  to 'acp' instead of 'non_interactive'. Commands with supportedModes that
  include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
  instead of duplicating the logic inline, preventing divergence.

Fixes review comments by wenshao and tanzhenxin on PR #3283.

* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts

* fix test ci
2026-04-20 14:34:43 +08:00

232 lines
6.1 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import type { HistoryItemInsightProgress } from '../types.js';
import { t } from '../../i18n/index.js';
import { join } from 'path';
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
import {
createDebugLogger,
encodeInsightProgressMessage,
encodeInsightReadyMessage,
Storage,
} from '@qwen-code/qwen-code-core';
import open from 'open';
const logger = createDebugLogger('DataProcessor');
export const insightCommand: SlashCommand = {
name: 'insight',
get description() {
return t(
'generate personalized programming insights from your chat history',
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: async (context: CommandContext) => {
try {
context.ui.setDebugMessage(t('Generating insights...'));
const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects');
if (!context.services.config) {
throw new Error('Config service is not available');
}
const insightGenerator = new StaticInsightGenerator(
context.services.config,
);
if (context.executionMode === 'acp') {
const pendingMessages: Array<{
messageType: 'info' | 'error';
content: string;
}> = [];
let isComplete = false;
let resume: (() => void) | null = null;
const flushResume = () => {
const resolve = resume;
if (!resolve) {
return;
}
resume = null;
resolve();
};
const pushMessage = (message: {
messageType: 'info' | 'error';
content: string;
}) => {
pendingMessages.push(message);
flushResume();
};
const streamMessages = async function* (): AsyncGenerator<
{ messageType: 'info' | 'error'; content: string },
void,
unknown
> {
while (!isComplete || pendingMessages.length > 0) {
if (pendingMessages.length === 0) {
await new Promise<void>((resolve) => {
resume = resolve;
});
}
while (pendingMessages.length > 0) {
const message = pendingMessages.shift();
if (message) {
yield message;
}
}
}
};
void (async () => {
try {
pushMessage({
messageType: 'info',
content: t('This may take a couple minutes. Sit tight!'),
});
pushMessage({
messageType: 'info',
content: encodeInsightProgressMessage(
t('Starting insight generation...'),
0,
),
});
const outputPath = await insightGenerator.generateStaticInsight(
projectsDir,
(stage, progress, detail) => {
pushMessage({
messageType: 'info',
content: encodeInsightProgressMessage(
stage,
progress,
detail,
),
});
},
);
pushMessage({
messageType: 'info',
content: encodeInsightReadyMessage(outputPath),
});
} catch (error) {
pushMessage({
messageType: 'error',
content: t('Failed to generate insights: {{error}}', {
error: (error as Error).message,
}),
});
logger.error('Insight generation error:', error);
} finally {
isComplete = true;
flushResume();
}
})();
return {
type: 'stream_messages',
messages: streamMessages(),
};
}
const updateProgress = (
stage: string,
progress: number,
detail?: string,
) => {
const progressItem: HistoryItemInsightProgress = {
type: MessageType.INSIGHT_PROGRESS,
progress: {
stage,
progress,
detail,
},
};
context.ui.setPendingItem(progressItem);
};
context.ui.addItem(
{
type: MessageType.INFO,
text: t('This may take a couple minutes. Sit tight!'),
},
Date.now(),
);
updateProgress(t('Starting insight generation...'), 0);
const outputPath = await insightGenerator.generateStaticInsight(
projectsDir,
updateProgress,
);
context.ui.setPendingItem(null);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Insight report generated successfully!'),
},
Date.now(),
);
try {
await open(outputPath);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Opening insights in your browser: {{path}}', {
path: outputPath,
}),
},
Date.now(),
);
} catch (browserError) {
logger.error('Failed to open browser automatically:', browserError);
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Insights generated at: {{path}}. Please open this file in your browser.',
{
path: outputPath,
},
),
},
Date.now(),
);
}
context.ui.setDebugMessage(t('Insights ready.'));
return;
} catch (error) {
context.ui.setPendingItem(null);
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to generate insights: {{error}}', {
error: (error as Error).message,
}),
},
Date.now(),
);
logger.error('Insight generation error:', error);
return;
}
},
};