From 93de70009ca73c7372cdc2829144ceafcd8729ed Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Fri, 3 Apr 2026 10:10:51 -0400 Subject: [PATCH] fix(cli): fix Tab completion for /chat and /resume subcommand menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change synthetic "list" suggestion to use insertValue: 'list' instead of submitValue, so Tab inserts text into the input bar rather than immediately executing the command - Add partial === '' guard so the synthetic entry only appears at the subcommand selection stage (e.g. /chat , not /chat li) - Rename the manual checkpoint subcommand from "list" to "checkpoints" to avoid name collision with the synthetic auto-saved "list" entry - Add trailing space after Tab-completing commands with subcommands (e.g. /chat → /chat ) so the subcommand menu appears --- packages/cli/src/ui/commands/chatCommand.ts | 2 +- .../src/ui/components/InputPrompt.test.tsx | 33 +++++ .../cli/src/ui/components/InputPrompt.tsx | 1 - .../__snapshots__/InputPrompt.test.tsx.snap | 118 ++++-------------- .../cli/src/ui/hooks/useCommandCompletion.tsx | 13 +- .../src/ui/hooks/useSlashCompletion.test.ts | 51 +++++++- .../cli/src/ui/hooks/useSlashCompletion.ts | 6 +- 7 files changed, 116 insertions(+), 108 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 05fd081dfb..71c590a05e 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -71,7 +71,7 @@ const getSavedChatTags = async ( }; const listCommand: SlashCommand = { - name: 'list', + name: 'checkpoints', description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 49dd08ac53..a677a1b95f 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1102,6 +1102,39 @@ describe('InputPrompt', () => { unmount(); }); + it('should autocomplete the synthetic auto suggestion on Tab (not submit)', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { + label: 'list', + value: 'list', + insertValue: 'list', + }, + ], + activeSuggestionIndex: 0, + }); + props.buffer.setText('/chat '); + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\t'); + }); + + await waitFor(() => + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0), + ); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + it('queues a message when Tab is pressed during generation', async () => { props.buffer.setText('A new prompt'); props.streamingState = StreamingState.Responding; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 4547c19d8a..7fa3f47ff8 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1066,7 +1066,6 @@ export const InputPrompt: React.FC = ({ handleSubmit(suggestion.submitValue.trim()); return true; } - const { isArgumentCompletion, leafCommand } = completion.slashCompletionRange; diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index ab6fe9b928..f40887b3b9 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -1,95 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the beginning of a line' in a multiline block 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > first line │ -│ second line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > first line │ -│ second line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > first line │ -│ second line │ -│ third line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > first line │ -│ │ -│ third line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > 👍A │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello 👍 │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > 👍 │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello 👍 world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > run @path/to/file │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - -exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > Type your message or @path/to/file │ -────────────────────────────────────────────────────────────────────────────────────────────────────" -`; - exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > second message @@ -168,18 +78,32 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub " `; -exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── -│ > hello │ -│ │ -│ world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > [Pasted Text: 10 lines] +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 4f89d69ff1..6ebf097929 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -442,11 +442,18 @@ export function useCommandCompletion({ if (completionMode === CompletionMode.SLASH) { const command = slashCompletionRange.getCommandFromSuggestion(suggestion); - // Don't add a space if the command has an action (can be executed) - // and doesn't have a completion function (doesn't REQUIRE more arguments) + // Don't add a space if the command has an action (can be executed), + // doesn't have a completion function (doesn't REQUIRE more arguments), + // AND doesn't have subcommands (no further navigation expected). const isExecutableCommand = !!(command && command.action); const requiresArguments = !!(command && command.completion); - shouldAddSpace = !isExecutableCommand || requiresArguments; + const hasSubCommands = !!( + command && + command.subCommands && + command.subCommands.length > 0 + ); + shouldAddSpace = + !isExecutableCommand || requiresArguments || hasSubCommands; } if ( diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 0bcb3863ce..67ca170caa 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -476,7 +476,7 @@ describe('useSlashCompletion', () => { expect(chatResult.current.suggestions[0]).toMatchObject({ label: 'list', sectionTitle: 'auto', - submitValue: '/chat', + insertValue: 'list', }); }); @@ -496,7 +496,7 @@ describe('useSlashCompletion', () => { expect(resumeResult.current.suggestions[0]).toMatchObject({ label: 'list', sectionTitle: 'auto', - submitValue: '/resume', + insertValue: 'list', }); }); @@ -513,6 +513,53 @@ describe('useSlashCompletion', () => { unmountResume(); }); + it('should prioritize matching subcommands over the synthetic auto entry when typing /chat subcommand prefixes', async () => { + const slashCommands = [ + createTestCommand({ + name: 'chat', + description: 'Chat command', + action: vi.fn(), + subCommands: [ + createTestCommand({ + name: 'list', + description: 'List checkpoints', + suggestionGroup: 'checkpoints', + action: vi.fn(), + }), + createTestCommand({ + name: 'resume', + description: 'Resume a saved chat', + action: vi.fn(), + }), + ], + }), + ]; + + const { result, unmount } = await renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/chat resu', + slashCommands, + mockCommandContext, + ), + ); + + await resolveMatch(); + + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(1); + expect(result.current.suggestions[0]).toMatchObject({ + label: 'resume', + value: 'resume', + }); + expect( + result.current.suggestions.some((s) => s.sectionTitle === 'auto'), + ).toBe(false); + }); + + unmount(); + }); + it('should sort exact altName matches to the top', async () => { const slashCommands = [ createTestCommand({ diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 7b06fdc1f4..fb47235bb9 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -336,16 +336,14 @@ function useCommandSuggestions( matchesCommand(leafCommand, commandPathParts[0]))) ); - if (isTopLevelChatOrResumeContext) { - const canonicalParentName = leafCommand.name; + if (isTopLevelChatOrResumeContext && partial === '') { const autoSectionSuggestion: Suggestion = { label: 'list', value: 'list', - insertValue: canonicalParentName, + insertValue: 'list', description: 'Browse auto-saved chats', commandKind: CommandKind.BUILT_IN, sectionTitle: 'auto', - submitValue: `/${canonicalParentName}`, }; setSuggestions([autoSectionSuggestion, ...finalSuggestions]); return;