feat(cli): unify /chat and /resume command UX (#20256)

This commit is contained in:
Dmitry Lyalin
2026-03-08 18:50:51 -04:00
committed by GitHub
parent d012929a28
commit d41735d6a9
18 changed files with 619 additions and 90 deletions

View File

@@ -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"

View File

@@ -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 &&

View File

@@ -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']),
);

View File

@@ -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 };
}