mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-03 06:00:49 +00:00
Merge pull request #1436 from QwenLM/feat/skills-enhancement
feat(skills): add experimental /skills command + hot reload
This commit is contained in:
commit
f3b7c63cd1
23 changed files with 563 additions and 105 deletions
|
|
@ -346,6 +346,7 @@ export async function main() {
|
|||
extensionEnablementManager,
|
||||
argv,
|
||||
);
|
||||
registerCleanup(() => config.shutdown());
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
console.log('Installed extensions:');
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
|
|||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
|
|
@ -78,6 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []),
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
themeCommand,
|
||||
|
|
|
|||
132
packages/cli/src/ui/commands/skillsCommand.ts
Normal file
132
packages/cli/src/ui/commands/skillsCommand.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandCompletionItem,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemSkillsList } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import type { SkillConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
get description() {
|
||||
return t('List available skills.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const rawArgs = args?.trim() ?? '';
|
||||
const [skillName = ''] = rawArgs.split(/\s+/);
|
||||
|
||||
const skillManager = context.services.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Could not retrieve skill manager.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const skills = await skillManager.listSkills();
|
||||
if (skills.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No skills are currently available.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skillName) {
|
||||
const sortedSkills = [...skills].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
const skillsListItem: HistoryItemSkillsList = {
|
||||
type: MessageType.SKILLS_LIST,
|
||||
skills: sortedSkills.map((skill) => ({ name: skill.name })),
|
||||
};
|
||||
context.ui.addItem(skillsListItem, Date.now());
|
||||
return;
|
||||
}
|
||||
const normalizedName = skillName.toLowerCase();
|
||||
const hasSkill = skills.some(
|
||||
(skill) => skill.name.toLowerCase() === normalizedName,
|
||||
);
|
||||
|
||||
if (!hasSkill) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Unknown skill: {{name}}', { name: skillName }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`;
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: rawInput }],
|
||||
};
|
||||
},
|
||||
completion: async (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
): Promise<CommandCompletionItem[]> => {
|
||||
const skillManager = context.services.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = await skillManager.listSkills();
|
||||
const normalizedPartial = partialArg.trim();
|
||||
const matches = await getSkillMatches(skills, normalizedPartial);
|
||||
|
||||
return matches.map((skill) => ({
|
||||
value: skill.name,
|
||||
description: skill.description,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
async function getSkillMatches(
|
||||
skills: SkillConfig[],
|
||||
query: string,
|
||||
): Promise<SkillConfig[]> {
|
||||
if (!query) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const names = skills.map((skill) => skill.name);
|
||||
const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
try {
|
||||
const fzf = new AsyncFzf(names, {
|
||||
fuzzy: 'v2',
|
||||
casing: 'case-insensitive',
|
||||
});
|
||||
const results = (await fzf.find(query)) as Array<{ item: string }>;
|
||||
return results
|
||||
.map((result) => skillMap.get(result.item))
|
||||
.filter((skill): skill is SkillConfig => !!skill);
|
||||
} catch (error) {
|
||||
console.error('[skillsCommand] Fuzzy match failed:', error);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return skills.filter((skill) =>
|
||||
skill.name.toLowerCase().startsWith(lowerQuery),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +209,12 @@ export enum CommandKind {
|
|||
MCP_PROMPT = 'mcp-prompt',
|
||||
}
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// The standardized contract for any command in the system.
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
|
|
@ -234,7 +240,7 @@ export interface SlashCommand {
|
|||
completion?: (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
) => Promise<string[]>;
|
||||
) => Promise<Array<string | CommandCompletionItem> | null>;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { Help } from './Help.js';
|
|||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { ExtensionsList } from './views/ExtensionsList.js';
|
||||
import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
|
||||
import { SkillsList } from './views/SkillsList.js';
|
||||
import { ToolsList } from './views/ToolsList.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
|
||||
|
|
@ -153,6 +154,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
showDescriptions={itemForDisplay.showDescriptions}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'skills_list' && (
|
||||
<SkillsList skills={itemForDisplay.skills} />
|
||||
)}
|
||||
{itemForDisplay.type === 'mcp_status' && (
|
||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function SuggestionsDisplay({
|
|||
</Box>
|
||||
|
||||
{suggestion.description && (
|
||||
<Box flexGrow={1} paddingLeft={3}>
|
||||
<Box flexGrow={1} paddingLeft={2}>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
|||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.status.warning}>{prefix}</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
|
|||
const prefixWidth = 3;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentYellow}>{prefix}</Text>
|
||||
</Box>
|
||||
|
|
|
|||
36
packages/cli/src/ui/components/views/SkillsList.tsx
Normal file
36
packages/cli/src/ui/components/views/SkillsList.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { type SkillDefinition } from '../../types.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface SkillsListProps {
|
||||
skills: readonly SkillDefinition[];
|
||||
}
|
||||
|
||||
export const SkillsList: React.FC<SkillsListProps> = ({ skills }) => (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Available skills:')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{skills.length > 0 ? (
|
||||
skills.map((skill) => (
|
||||
<Box key={skill.name} flexDirection="row">
|
||||
<Text color={theme.text.primary}>{' '}- </Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text color={theme.text.primary}> {t('No skills available')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -573,6 +573,45 @@ describe('useSlashCompletion', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should map completion items with descriptions for argument suggestions', async () => {
|
||||
const mockCompletionFn = vi.fn().mockResolvedValue([
|
||||
{ value: 'pdf', description: 'Create PDF documents' },
|
||||
{ value: 'xlsx', description: 'Work with spreadsheets' },
|
||||
]);
|
||||
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'skills',
|
||||
description: 'List available skills',
|
||||
completion: mockCompletionFn,
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/skills ',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{
|
||||
label: 'pdf',
|
||||
value: 'pdf',
|
||||
description: 'Create PDF documents',
|
||||
},
|
||||
{
|
||||
label: 'xlsx',
|
||||
value: 'xlsx',
|
||||
description: 'Work with spreadsheets',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call command.completion with an empty string when args start with a space', async () => {
|
||||
const mockCompletionFn = vi
|
||||
.fn()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AsyncFzf } from 'fzf';
|
|||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandCompletionItem,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from '../commands/types.js';
|
||||
|
|
@ -215,10 +216,9 @@ function useCommandSuggestions(
|
|||
)) || [];
|
||||
|
||||
if (!signal.aborted) {
|
||||
const finalSuggestions = results.map((s) => ({
|
||||
label: s,
|
||||
value: s,
|
||||
}));
|
||||
const finalSuggestions = results
|
||||
.map((item) => toSuggestion(item))
|
||||
.filter((suggestion): suggestion is Suggestion => !!suggestion);
|
||||
setSuggestions(finalSuggestions);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -310,6 +310,20 @@ function useCommandSuggestions(
|
|||
return { suggestions, isLoading };
|
||||
}
|
||||
|
||||
function toSuggestion(item: string | CommandCompletionItem): Suggestion | null {
|
||||
if (typeof item === 'string') {
|
||||
return { label: item, value: item };
|
||||
}
|
||||
if (!item.value) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: item.label ?? item.value,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
};
|
||||
}
|
||||
|
||||
function useCompletionPositions(
|
||||
query: string | null,
|
||||
parserResult: CommandParserResult,
|
||||
|
|
|
|||
|
|
@ -201,12 +201,21 @@ export interface ToolDefinition {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
export interface SkillDefinition {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type HistoryItemToolsList = HistoryItemBase & {
|
||||
type: 'tools_list';
|
||||
tools: ToolDefinition[];
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemSkillsList = HistoryItemBase & {
|
||||
type: 'skills_list';
|
||||
skills: SkillDefinition[];
|
||||
};
|
||||
|
||||
// JSON-friendly types for using as a simple data model showing info about an
|
||||
// MCP Server.
|
||||
export interface JsonMcpTool {
|
||||
|
|
@ -268,6 +277,7 @@ export type HistoryItemWithoutId =
|
|||
| HistoryItemCompression
|
||||
| HistoryItemExtensionsList
|
||||
| HistoryItemToolsList
|
||||
| HistoryItemSkillsList
|
||||
| HistoryItemMcpStatus;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
|
@ -289,6 +299,7 @@ export enum MessageType {
|
|||
SUMMARY = 'summary',
|
||||
EXTENSIONS_LIST = 'extensions_list',
|
||||
TOOLS_LIST = 'tools_list',
|
||||
SKILLS_LIST = 'skills_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue