diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 9996980f31a..535982255c5 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -221,6 +221,7 @@ describe('Session', () => { { name: 'init', description: 'Initialize project context', + argumentHint: '[path]', }, ]); @@ -239,7 +240,7 @@ describe('Session', () => { { name: 'init', description: 'Initialize project context', - input: null, + input: { hint: '[path]' }, }, ], }, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9e921434ac3..cc27ddcba5e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -981,7 +981,7 @@ export class Session implements SessionContext { (cmd) => ({ name: cmd.name, description: cmd.description, - input: null, + input: cmd.argumentHint ? { hint: cmd.argumentHint } : null, }), ); diff --git a/packages/cli/src/services/BundledSkillLoader.test.ts b/packages/cli/src/services/BundledSkillLoader.test.ts index a2bd4544c90..5c99f3afd78 100644 --- a/packages/cli/src/services/BundledSkillLoader.test.ts +++ b/packages/cli/src/services/BundledSkillLoader.test.ts @@ -69,6 +69,16 @@ describe('BundledSkillLoader', () => { expect(mockSkillManager.listSkills).not.toHaveBeenCalled(); }); + it('should propagate argumentHint from bundled skills to slash commands', async () => { + const skill = makeSkill({ argumentHint: '[topic]' }); + mockSkillManager.listSkills.mockResolvedValue([skill]); + + const loader = new BundledSkillLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0]?.argumentHint).toBe('[topic]'); + }); + it('should load bundled skills as slash commands', async () => { const skill = makeSkill(); mockSkillManager.listSkills.mockResolvedValue([skill]); diff --git a/packages/cli/src/services/BundledSkillLoader.ts b/packages/cli/src/services/BundledSkillLoader.ts index 5ea6682b55e..d5f36c93e16 100644 --- a/packages/cli/src/services/BundledSkillLoader.ts +++ b/packages/cli/src/services/BundledSkillLoader.ts @@ -66,6 +66,7 @@ export class BundledSkillLoader implements ICommandLoader { source: 'bundled-skill' as const, sourceLabel: 'Skill', modelInvocable: !skill.disableModelInvocation, + argumentHint: skill.argumentHint, whenToUse: skill.whenToUse, action: async (context, _args): Promise => { // Resolve template variables in skill body diff --git a/packages/cli/src/services/FileCommandLoader-markdown.test.ts b/packages/cli/src/services/FileCommandLoader-markdown.test.ts index 737cc39dbf4..816bc1269b5 100644 --- a/packages/cli/src/services/FileCommandLoader-markdown.test.ts +++ b/packages/cli/src/services/FileCommandLoader-markdown.test.ts @@ -27,6 +27,7 @@ describe('FileCommandLoader - Markdown support', () => { // Create a test markdown command file const mdContent = `--- description: Test markdown command +argument-hint: "[issue-number]" --- This is a test prompt from markdown.`; @@ -47,6 +48,7 @@ This is a test prompt from markdown.`; expect(commands).toHaveLength(1); expect(commands[0].name).toBe('test-command'); expect(commands[0].description).toBe('Test markdown command'); + expect(commands[0].argumentHint).toBe('[issue-number]'); } finally { // Restore original method loader['getCommandDirectories'] = originalMethod; diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index ba03c95671a..61bb7821032 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -355,6 +355,7 @@ export class FileCommandLoader implements ICommandLoader { ? validDef.frontmatter.description : undefined, whenToUse: validDef.frontmatter?.when_to_use, + argumentHint: validDef.frontmatter?.['argument-hint'], disableModelInvocation: validDef.frontmatter?.['disable-model-invocation'], }; diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts index 00c189123be..a884d2c4f3d 100644 --- a/packages/cli/src/services/SkillCommandLoader.test.ts +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -58,6 +58,19 @@ describe('SkillCommandLoader', () => { expect(mockSkillManager.listSkills).not.toHaveBeenCalled(); }); + it('should propagate argumentHint from skills to slash commands', async () => { + const skill = makeSkill({ argumentHint: '[topic]' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0]?.argumentHint).toBe('[topic]'); + }); + it('should query user, project, and extension levels', async () => { const loader = new SkillCommandLoader(mockConfig); await loader.loadCommands(signal); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts index 58e297633f1..daf7bc9184c 100644 --- a/packages/cli/src/services/SkillCommandLoader.ts +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -84,6 +84,7 @@ export class SkillCommandLoader implements ICommandLoader { : 'skill-dir-command') as CommandSource, sourceLabel, modelInvocable, + argumentHint: skill.argumentHint, whenToUse: skill.whenToUse, action: async (context, _args): Promise => { const body = skill.body; diff --git a/packages/cli/src/services/command-factory.ts b/packages/cli/src/services/command-factory.ts index 1aa363254ba..ef3f91bee3f 100644 --- a/packages/cli/src/services/command-factory.ts +++ b/packages/cli/src/services/command-factory.ts @@ -37,6 +37,7 @@ import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; export interface CommandDefinition { prompt: string; description?: string; + argumentHint?: string; whenToUse?: string; disableModelInvocation?: boolean; } @@ -121,6 +122,7 @@ export function createSlashCommandFromDefinition( modelInvocable: definition.disableModelInvocation ? false : !extensionName || !!(definition.description || definition.whenToUse), + argumentHint: definition.argumentHint, whenToUse: definition.whenToUse, action: async ( context: CommandContext, diff --git a/packages/cli/src/services/markdown-command-parser.test.ts b/packages/cli/src/services/markdown-command-parser.test.ts index bbefa43a4f1..da71db1a826 100644 --- a/packages/cli/src/services/markdown-command-parser.test.ts +++ b/packages/cli/src/services/markdown-command-parser.test.ts @@ -14,6 +14,7 @@ describe('parseMarkdownCommand', () => { it('should parse markdown with YAML frontmatter', () => { const content = `--- description: Test command +argument-hint: "[issue-number]" --- This is the prompt content.`; @@ -23,6 +24,7 @@ This is the prompt content.`; expect(result).toEqual({ frontmatter: { description: 'Test command', + 'argument-hint': '[issue-number]', }, prompt: 'This is the prompt content.', }); @@ -146,6 +148,7 @@ describe('MarkdownCommandDefSchema', () => { const validDef = { frontmatter: { description: 'Test description', + 'argument-hint': '[issue-number]', }, prompt: 'Test prompt', }; diff --git a/packages/cli/src/services/markdown-command-parser.ts b/packages/cli/src/services/markdown-command-parser.ts index bfe5a3e2b66..8e0e0139c92 100644 --- a/packages/cli/src/services/markdown-command-parser.ts +++ b/packages/cli/src/services/markdown-command-parser.ts @@ -18,6 +18,7 @@ export const MarkdownCommandDefSchema = z.object({ frontmatter: z .object({ description: z.string().optional(), + 'argument-hint': z.string().optional(), when_to_use: z.string().optional(), 'disable-model-invocation': z.boolean().optional(), }) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index bbdf36f5c84..cf457454df9 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -194,6 +194,8 @@ export const InputPrompt: React.FC = ({ const midInputGhostTextRef = useRef<{ text: string; insertPosition: number; + acceptText?: string; + showCursorBeforeText?: boolean; } | null>(null); midInputGhostTextRef.current = completion.midInputGhostText; @@ -827,9 +829,9 @@ export const InputPrompt: React.FC = ({ !key.paste && !key.shift && !completion.showSuggestions && - midInputGhostTextRef.current + midInputGhostTextRef.current?.acceptText ) { - buffer.insert(midInputGhostTextRef.current.text); + buffer.insert(midInputGhostTextRef.current.acceptText); return true; } @@ -1169,18 +1171,29 @@ export const InputPrompt: React.FC = ({ // Check for mid-input ghost text (only renders when cursor is at end of input) const ghostText = midInputGhostTextRef.current; if (ghostText && showCursorOpt && ghostText.text.length > 0) { - // First ghost char: inverted (as cursor). Rest: dimmed gray. - const firstChar = ghostText.text[0]!; - const rest = ghostText.text.slice(firstChar.length); - renderedLine.push( - {chalk.inverse(firstChar)}, - ); - if (rest.length > 0) { + if (ghostText.showCursorBeforeText) { + renderedLine.push( + {chalk.inverse(' ')}, + ); renderedLine.push( - {rest} + {ghostText.text} , ); + } else { + // First ghost char: inverted (as cursor). Rest: dimmed gray. + const firstChar = ghostText.text[0]!; + const rest = ghostText.text.slice(firstChar.length); + renderedLine.push( + {chalk.inverse(firstChar)}, + ); + if (rest.length > 0) { + renderedLine.push( + + {rest} + , + ); + } } renderedLine.push({`\u200B`}); } else { diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index 74e5e942d01..25f2f4924f6 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -9,7 +9,8 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useCommandCompletion } from './useCommandCompletion.js'; -import type { CommandContext } from '../commands/types.js'; +import type { CommandContext, SlashCommand } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useEffect } from 'react'; @@ -585,4 +586,62 @@ describe('useCommandCompletion', () => { ); }); }); + + describe('argument hint ghost text', () => { + it('shows argumentHint as inline ghost text for a complete slash command', () => { + const slashCommands: SlashCommand[] = [ + { + name: 'fix-issue', + description: 'Fix GitHub issue', + argumentHint: '[issue-number]', + kind: CommandKind.FILE, + }, + ]; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/fix-issue'); + const completion = useCommandCompletion( + textBuffer, + testRootDir, + slashCommands, + mockCommandContext, + false, + mockConfig, + ); + return completion; + }); + + expect(result.current.midInputGhostText).toEqual({ + text: '[issue-number]', + insertPosition: '/fix-issue'.length, + showCursorBeforeText: true, + }); + }); + + it('does not show argumentHint after arguments have started', () => { + const slashCommands: SlashCommand[] = [ + { + name: 'fix-issue', + description: 'Fix GitHub issue', + argumentHint: '[issue-number]', + kind: CommandKind.FILE, + }, + ]; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/fix-issue 123'); + const completion = useCommandCompletion( + textBuffer, + testRootDir, + slashCommands, + mockCommandContext, + false, + mockConfig, + ); + return completion; + }); + + expect(result.current.midInputGhostText).toBeNull(); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index cedea5c5661..0b1cfd72feb 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -19,6 +19,7 @@ import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { useCompletion } from './useCompletion.js'; +import { parseSlashCommand } from '../../utils/commands.js'; export enum CompletionMode { IDLE = 'IDLE', @@ -40,7 +41,12 @@ export interface UseCommandCompletionReturn { navigateDown: () => void; handleAutocomplete: (indexToUse: number) => void; /** Inline ghost text for mid-input slash commands (not at line start). */ - midInputGhostText: { text: string; insertPosition: number } | null; + midInputGhostText: { + text: string; + insertPosition: number; + acceptText?: string; + showCursorBeforeText?: boolean; + } | null; } export function useCommandCompletion( @@ -243,17 +249,46 @@ export function useCommandCompletion( const midInputGhostText = useMemo((): { text: string; insertPosition: number; + acceptText?: string; + showCursorBeforeText?: boolean; } | null => { if (!active || reverseSearchActive) return null; const cursorOffset = logicalPosToOffset(buffer.lines, cursorRow, cursorCol); const midCmd = findMidInputSlashCommand(buffer.text, cursorOffset); - if (!midCmd) return null; - const match = getBestSlashCommandMatch( - midCmd.partialCommand, + if (midCmd) { + const match = getBestSlashCommandMatch( + midCmd.partialCommand, + slashCommands, + ); + if (!match) return null; + return { + text: match.suffix, + insertPosition: cursorOffset, + acceptText: match.suffix, + }; + } + + if (cursorRow !== 0) return null; + const currentLine = buffer.lines[cursorRow] || ''; + const lineCodePoints = toCodePoints(currentLine); + if (cursorCol !== lineCodePoints.length) return null; + + const lineToCursor = lineCodePoints.slice(0, cursorCol).join(''); + if (!isSlashCommand(lineToCursor.trim())) return null; + + const { commandToExecute, args } = parseSlashCommand( + lineToCursor, slashCommands, ); - if (!match) return null; - return { text: match.suffix, insertPosition: cursorOffset }; + if (!commandToExecute?.argumentHint || args.trim().length > 0) { + return null; + } + + return { + text: commandToExecute.argumentHint, + insertPosition: cursorOffset, + showCursorBeforeText: true, + }; }, [ buffer.text, buffer.lines, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index a526ce6b3d6..dbe7a601c20 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -246,6 +246,35 @@ describe('useSlashCompletion', () => { }); }); + it('should keep argumentHint out of command suggestion labels', async () => { + const slashCommands = [ + createTestCommand({ + name: 'fix-issue', + description: 'Fix GitHub issue', + argumentHint: '[issue-number]', + }), + ]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/fix', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { + label: 'fix-issue', + value: 'fix-issue', + description: 'Fix GitHub issue', + commandKind: CommandKind.BUILT_IN, + }, + ]); + }); + }); + it('should prefer higher completionPriority when match quality ties', async () => { const slashCommands = [ createTestCommand({ diff --git a/packages/core/src/skills/bundled/batch/SKILL.md b/packages/core/src/skills/bundled/batch/SKILL.md index b6b9c9e8dec..a44d1cdacf8 100644 --- a/packages/core/src/skills/bundled/batch/SKILL.md +++ b/packages/core/src/skills/bundled/batch/SKILL.md @@ -1,6 +1,7 @@ --- name: batch description: Execute batch operations on multiple files in parallel. Automatically discovers files, splits into chunks, and processes with parallel worker agents. Use `/batch` followed by operation and file pattern. +argument-hint: ' ' allowedTools: - task - glob diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md index ea9ae7cf357..70bc4941877 100644 --- a/packages/core/src/skills/bundled/loop/SKILL.md +++ b/packages/core/src/skills/bundled/loop/SKILL.md @@ -1,6 +1,7 @@ --- name: loop description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). /loop list to show jobs, /loop clear to cancel all. +argument-hint: '[interval] | list | clear' allowedTools: - cron_create - cron_list diff --git a/packages/core/src/skills/bundled/qc-helper/SKILL.md b/packages/core/src/skills/bundled/qc-helper/SKILL.md index 14fbaa15203..bc68011a50d 100644 --- a/packages/core/src/skills/bundled/qc-helper/SKILL.md +++ b/packages/core/src/skills/bundled/qc-helper/SKILL.md @@ -1,6 +1,7 @@ --- name: qc-helper description: Answer any question about Qwen Code usage, features, configuration, and troubleshooting by referencing the official user documentation. Also helps users view or modify their settings.json. Invoke with `/qc-helper` followed by a question, e.g. `/qc-helper how do I configure MCP servers?` or `/qc-helper change approval mode to yolo`. +argument-hint: '' allowedTools: - read_file - edit_file diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index 62022b3c5c0..00e7fa34da3 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -1,6 +1,7 @@ --- name: review description: Review changed code for correctness, security, code quality, and performance. Use when the user asks to review code changes, a PR, or specific files. Invoke with `/review`, `/review `, `/review `, or `/review --comment` to post inline comments on the PR. +argument-hint: '[pr-number|file-path] [--comment]' allowedTools: - task - run_shell_command diff --git a/packages/core/src/skills/skill-load.test.ts b/packages/core/src/skills/skill-load.test.ts index af382b7b889..fde14b3899d 100644 --- a/packages/core/src/skills/skill-load.test.ts +++ b/packages/core/src/skills/skill-load.test.ts @@ -43,6 +43,13 @@ describe('skill-load', () => { allowedTools: ['read_file', 'write_file'], }; } + if (yamlString.includes('argument-hint:')) { + return { + name: 'test-skill', + description: 'A test skill', + 'argument-hint': '[topic]', + }; + } // Default case return { name: 'test-skill', @@ -174,6 +181,21 @@ You are a helpful assistant with this skill. expect(config.allowedTools).toEqual(['read_file', 'write_file']); }); + it('should parse argument-hint from frontmatter', () => { + const markdownWithArgumentHint = `--- +name: test-skill +description: A test skill +argument-hint: "[topic]" +--- + +Skill body. +`; + + const config = parseSkillContent(markdownWithArgumentHint, testFilePath); + + expect(config.argumentHint).toBe('[topic]'); + }); + it('should throw error for invalid format without frontmatter', () => { const invalidMarkdown = `# Just a heading Some content without frontmatter. diff --git a/packages/core/src/skills/skill-load.ts b/packages/core/src/skills/skill-load.ts index 97044158897..9744c7eefbe 100644 --- a/packages/core/src/skills/skill-load.ts +++ b/packages/core/src/skills/skill-load.ts @@ -114,11 +114,16 @@ export function parseSkillContent( // Extract optional model field const model = parseModelField(frontmatter); + const argumentHint = + typeof frontmatter['argument-hint'] === 'string' + ? frontmatter['argument-hint'] + : undefined; const config: SkillConfig = { name, description, allowedTools, + argumentHint, model, filePath, body: body.trim(), diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 9dd23ed6232..841bc350fcf 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -82,6 +82,13 @@ describe('SkillManager', () => { allowedTools: ['read_file', 'write_file'], }; } + if (yamlString.includes('argument-hint:')) { + return { + name: 'test-skill', + description: 'A test skill', + 'argument-hint': '[topic]', + }; + } if (yamlString.includes('name: skill1')) { return { name: 'skill1', description: 'First skill' }; } @@ -239,6 +246,25 @@ You are a helpful assistant with this skill. expect(config.allowedTools).toEqual(['read_file', 'write_file']); }); + it('should parse argument-hint from frontmatter', () => { + const markdownWithArgumentHint = `--- +name: test-skill +description: A test skill +argument-hint: "[topic]" +--- + +Skill body. +`; + + const config = manager.parseSkillContent( + markdownWithArgumentHint, + validSkillConfig.filePath, + 'project', + ); + + expect(config.argumentHint).toBe('[topic]'); + }); + it('should determine level from file path', () => { const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md'; const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md'; diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 0c92e076196..2ab6f9ebd12 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -450,7 +450,11 @@ export class SkillManager { // Extract optional model field const model = parseModelField(frontmatter); - // Extract when_to_use and disable-model-invocation + // Extract argument-hint, when_to_use, and disable-model-invocation + const argumentHint = + typeof frontmatter['argument-hint'] === 'string' + ? frontmatter['argument-hint'] + : undefined; const whenToUse = typeof frontmatter['when_to_use'] === 'string' ? frontmatter['when_to_use'] @@ -468,6 +472,7 @@ export class SkillManager { allowedTools, hooks, skillRoot, + argumentHint, model, level, filePath, diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index fe73324669a..8e0655e305f 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -81,6 +81,12 @@ export interface SkillConfig { */ extensionName?: string; + /** + * Argument hint shown after the slash command name in completion menus. + * Parsed from the `argument-hint` frontmatter field in SKILL.md. + */ + argumentHint?: string; + /** * Describes when to invoke this skill — shown to the model in the SkillTool * description so it can decide whether to use it. Parsed from the