mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-13 23:01:09 -07:00
feat(cli): unify /chat and /resume command UX (#20256)
This commit is contained in:
@@ -493,6 +493,31 @@ describe('useCommandCompletion', () => {
|
||||
expect(result.current.textBuffer.text).toBe('@src/file1.txt ');
|
||||
});
|
||||
|
||||
it('should insert canonical slash command text when suggestion provides insertValue', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [
|
||||
{
|
||||
label: 'list',
|
||||
value: 'list',
|
||||
insertValue: 'resume list',
|
||||
},
|
||||
],
|
||||
slashCompletionRange: { completionStart: 1, completionEnd: 5 },
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook('/resu');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/resume list ');
|
||||
});
|
||||
|
||||
it('should complete a file path when cursor is not at the end of the line', async () => {
|
||||
const text = '@src/fi is a good file';
|
||||
const cursorOffset = 7; // after "i"
|
||||
|
||||
@@ -374,7 +374,7 @@ export function useCommandCompletion({
|
||||
}
|
||||
|
||||
// Apply space padding for slash commands (needed for subcommands like "/chat list")
|
||||
let suggestionText = suggestion.value;
|
||||
let suggestionText = suggestion.insertValue ?? suggestion.value;
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
// Add leading space if completing a subcommand (cursor is after parent command with no space)
|
||||
if (start === end && start > 1 && currentLine[start - 1] !== ' ') {
|
||||
@@ -423,7 +423,7 @@ export function useCommandCompletion({
|
||||
}
|
||||
|
||||
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
|
||||
let suggestionText = suggestion.value;
|
||||
let suggestionText = suggestion.insertValue ?? suggestion.value;
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
if (
|
||||
start === end &&
|
||||
|
||||
@@ -438,6 +438,129 @@ describe('useSlashCompletion', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show the same selectable auto/checkpoint menu for /chat and /resume', async () => {
|
||||
const checkpointSubCommands = [
|
||||
createTestCommand({
|
||||
name: 'list',
|
||||
description: 'List checkpoints',
|
||||
suggestionGroup: 'checkpoints',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'save',
|
||||
description: 'Save checkpoint',
|
||||
suggestionGroup: 'checkpoints',
|
||||
action: vi.fn(),
|
||||
}),
|
||||
];
|
||||
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'chat',
|
||||
description: 'Chat command',
|
||||
action: vi.fn(),
|
||||
subCommands: checkpointSubCommands,
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'resume',
|
||||
description: 'Resume command',
|
||||
action: vi.fn(),
|
||||
subCommands: checkpointSubCommands,
|
||||
}),
|
||||
];
|
||||
|
||||
const { result: chatResult, unmount: unmountChat } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/chat',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatResult.current.suggestions[0]).toMatchObject({
|
||||
label: 'list',
|
||||
sectionTitle: 'auto',
|
||||
submitValue: '/chat',
|
||||
});
|
||||
});
|
||||
|
||||
const { result: resumeResult, unmount: unmountResume } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/resume',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeResult.current.suggestions[0]).toMatchObject({
|
||||
label: 'list',
|
||||
sectionTitle: 'auto',
|
||||
submitValue: '/resume',
|
||||
});
|
||||
});
|
||||
|
||||
const chatCheckpointLabels = chatResult.current.suggestions
|
||||
.slice(1)
|
||||
.map((s) => s.label);
|
||||
const resumeCheckpointLabels = resumeResult.current.suggestions
|
||||
.slice(1)
|
||||
.map((s) => s.label);
|
||||
|
||||
expect(chatCheckpointLabels).toEqual(resumeCheckpointLabels);
|
||||
|
||||
unmountChat();
|
||||
unmountResume();
|
||||
});
|
||||
|
||||
it('should show the grouped /resume menu for unique /resum prefix input', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
name: 'resume',
|
||||
description: 'Resume command',
|
||||
action: vi.fn(),
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'list',
|
||||
description: 'List checkpoints',
|
||||
suggestionGroup: 'checkpoints',
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'save',
|
||||
description: 'Save checkpoint',
|
||||
suggestionGroup: 'checkpoints',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTestHarnessForSlashCompletion(
|
||||
true,
|
||||
'/resum',
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions[0]).toMatchObject({
|
||||
label: 'list',
|
||||
sectionTitle: 'auto',
|
||||
submitValue: '/resume',
|
||||
});
|
||||
expect(result.current.isPerfectMatch).toBe(false);
|
||||
expect(result.current.suggestions.slice(1).map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['list', 'save']),
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should sort exact altName matches to the top', async () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
@@ -492,8 +615,13 @@ describe('useSlashCompletion', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show subcommands of 'chat'
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
// Should show the auto-session entry plus subcommands of 'chat'
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
expect(result.current.suggestions[0]).toMatchObject({
|
||||
label: 'list',
|
||||
sectionTitle: 'auto',
|
||||
submitValue: '/chat',
|
||||
});
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['list', 'save']),
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ interface CommandParserResult {
|
||||
currentLevel: readonly SlashCommand[] | undefined;
|
||||
leafCommand: SlashCommand | null;
|
||||
exactMatchAsParent: SlashCommand | undefined;
|
||||
usedPrefixParentDescent: boolean;
|
||||
isArgumentCompletion: boolean;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ function useCommandParser(
|
||||
currentLevel: slashCommands,
|
||||
leafCommand: null,
|
||||
exactMatchAsParent: undefined,
|
||||
usedPrefixParentDescent: false,
|
||||
isArgumentCompletion: false,
|
||||
};
|
||||
}
|
||||
@@ -88,6 +90,7 @@ function useCommandParser(
|
||||
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
let usedPrefixParentDescent = false;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
@@ -138,6 +141,32 @@ function useCommandParser(
|
||||
partial = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Phase-one alias UX: allow unique prefix descent for /chat and /resume
|
||||
// so `/cha` and `/resum` expose the same grouped menu immediately.
|
||||
if (!exactMatchAsParent && partial && currentLevel) {
|
||||
const prefixParentMatches = currentLevel.filter(
|
||||
(cmd) =>
|
||||
!!cmd.subCommands &&
|
||||
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
|
||||
cmd.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(partial.toLowerCase()),
|
||||
)),
|
||||
);
|
||||
|
||||
if (prefixParentMatches.length === 1) {
|
||||
const candidate = prefixParentMatches[0];
|
||||
if (candidate.name === 'chat' || candidate.name === 'resume') {
|
||||
exactMatchAsParent = candidate;
|
||||
leafCommand = candidate;
|
||||
usedPrefixParentDescent = true;
|
||||
currentLevel = candidate.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
partial = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
@@ -154,6 +183,7 @@ function useCommandParser(
|
||||
currentLevel,
|
||||
leafCommand,
|
||||
exactMatchAsParent,
|
||||
usedPrefixParentDescent,
|
||||
isArgumentCompletion,
|
||||
};
|
||||
}, [query, slashCommands]);
|
||||
@@ -312,12 +342,53 @@ function useCommandSuggestions(
|
||||
return 0;
|
||||
});
|
||||
|
||||
const finalSuggestions = sortedSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
commandKind: cmd.kind,
|
||||
}));
|
||||
const finalSuggestions = sortedSuggestions.map((cmd) => {
|
||||
const canonicalParentName =
|
||||
parserResult.usedPrefixParentDescent &&
|
||||
leafCommand &&
|
||||
(leafCommand.name === 'chat' || leafCommand.name === 'resume')
|
||||
? leafCommand.name
|
||||
: undefined;
|
||||
|
||||
const suggestion: Suggestion = {
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
insertValue: canonicalParentName
|
||||
? `${canonicalParentName} ${cmd.name}`
|
||||
: undefined,
|
||||
description: cmd.description,
|
||||
commandKind: cmd.kind,
|
||||
};
|
||||
|
||||
if (cmd.suggestionGroup) {
|
||||
suggestion.sectionTitle = cmd.suggestionGroup;
|
||||
}
|
||||
|
||||
return suggestion;
|
||||
});
|
||||
|
||||
const isTopLevelChatOrResumeContext = !!(
|
||||
leafCommand &&
|
||||
(leafCommand.name === 'chat' || leafCommand.name === 'resume') &&
|
||||
(commandPathParts.length === 0 ||
|
||||
(commandPathParts.length === 1 &&
|
||||
matchesCommand(leafCommand, commandPathParts[0])))
|
||||
);
|
||||
|
||||
if (isTopLevelChatOrResumeContext) {
|
||||
const canonicalParentName = leafCommand.name;
|
||||
const autoSectionSuggestion: Suggestion = {
|
||||
label: 'list',
|
||||
value: 'list',
|
||||
insertValue: canonicalParentName,
|
||||
description: 'Browse auto-saved chats',
|
||||
commandKind: CommandKind.BUILT_IN,
|
||||
sectionTitle: 'auto',
|
||||
submitValue: `/${leafCommand.name}`,
|
||||
};
|
||||
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
}
|
||||
@@ -359,7 +430,9 @@ function useCompletionPositions(
|
||||
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
|
||||
|
||||
// Set completion start/end positions
|
||||
if (hasTrailingSpace || exactMatchAsParent) {
|
||||
if (parserResult.usedPrefixParentDescent) {
|
||||
return { start: 1, end: query.length };
|
||||
} else if (hasTrailingSpace || exactMatchAsParent) {
|
||||
return { start: query.length, end: query.length };
|
||||
} else if (partial) {
|
||||
if (parserResult.isArgumentCompletion) {
|
||||
@@ -388,7 +461,12 @@ function usePerfectMatch(
|
||||
return { isPerfectMatch: false };
|
||||
}
|
||||
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
if (
|
||||
leafCommand &&
|
||||
partial === '' &&
|
||||
leafCommand.action &&
|
||||
!parserResult.usedPrefixParentDescent
|
||||
) {
|
||||
return { isPerfectMatch: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user