Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ describe('Session', () => {
{
name: 'init',
description: 'Initialize project context',
argumentHint: '[path]',
},
]);

Expand All @@ -239,7 +240,7 @@ describe('Session', () => {
{
name: 'init',
description: 'Initialize project context',
input: null,
input: { hint: '[path]' },
},
],
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/services/BundledSkillLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/services/BundledSkillLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlashCommandActionReturn> => {
// Resolve template variables in skill body
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/FileCommandLoader-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/services/FileCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/services/SkillCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/services/SkillCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlashCommandActionReturn> => {
const body = skill.body;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/command-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
export interface CommandDefinition {
prompt: string;
description?: string;
argumentHint?: string;
whenToUse?: string;
disableModelInvocation?: boolean;
}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/services/markdown-command-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand All @@ -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.',
});
Expand Down Expand Up @@ -146,6 +148,7 @@ describe('MarkdownCommandDefSchema', () => {
const validDef = {
frontmatter: {
description: 'Test description',
'argument-hint': '[issue-number]',
},
prompt: 'Test prompt',
};
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/services/markdown-command-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
33 changes: 23 additions & 10 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const midInputGhostTextRef = useRef<{
text: string;
insertPosition: number;
acceptText?: string;
showCursorBeforeText?: boolean;
} | null>(null);
midInputGhostTextRef.current = completion.midInputGhostText;

Expand Down Expand Up @@ -827,9 +829,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
!key.paste &&
!key.shift &&
!completion.showSuggestions &&
midInputGhostTextRef.current
midInputGhostTextRef.current?.acceptText
) {
buffer.insert(midInputGhostTextRef.current.text);
buffer.insert(midInputGhostTextRef.current.acceptText);
return true;
}

Expand Down Expand Up @@ -1169,18 +1171,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// 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(
<Text key="ghost-cursor">{chalk.inverse(firstChar)}</Text>,
);
if (rest.length > 0) {
if (ghostText.showCursorBeforeText) {
renderedLine.push(
<Text key="ghost-cursor">{chalk.inverse(' ')}</Text>,
);
renderedLine.push(
<Text key="ghost-rest" color={theme.text.secondary}>
{rest}
{ghostText.text}
</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(
<Text key="ghost-cursor">{chalk.inverse(firstChar)}</Text>,
);
if (rest.length > 0) {
renderedLine.push(
<Text key="ghost-rest" color={theme.text.secondary}>
{rest}
</Text>,
);
}
}
renderedLine.push(<Text key="ghost-zwsp">{`\u200B`}</Text>);
} else {
Expand Down
61 changes: 60 additions & 1 deletion packages/cli/src/ui/hooks/useCommandCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
});
47 changes: 41 additions & 6 deletions packages/cli/src/ui/hooks/useCommandCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/ui/hooks/useSlashCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading