diff --git a/.changeset/fix-slash-subcommand-after-tab.md b/.changeset/fix-slash-subcommand-after-tab.md new file mode 100644 index 0000000000..51a6dedcd8 --- /dev/null +++ b/.changeset/fix-slash-subcommand-after-tab.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show subcommand suggestions after Tab-completing a slash command name. diff --git a/.changeset/fix-tab-file-completion.md b/.changeset/fix-tab-file-completion.md new file mode 100644 index 0000000000..5fd1a5183f --- /dev/null +++ b/.changeset/fix-tab-file-completion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the Tab key unexpectedly opening the file completion list. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 28fba5fead..6008175470 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -404,20 +404,54 @@ export class CustomEditor extends Editor { return; } + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + return; + } + super.handleInput(normalized); - this.reopenPathCompletionAfterInput(); + this.reopenAutocompleteAfterInput(); } - private reopenPathCompletionAfterInput(): void { + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; const { line, col } = this.getCursor(); const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; - if (!textBeforeCursor.endsWith('/')) return; - if (this.isShowingAutocomplete()) return; - const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); - const isAtMention = extractAtPrefix(textBeforeCursor) !== null; - if (!isSlashArgument && !isAtMention) return; - (this as unknown as { requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void }) - .requestAutocomplete?.({ force: true, explicitTab: false }); + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + if (isSlashArgument || isAtMention) { + trigger(); + } + return; + } + + // After accepting a slash command name via Tab, pi-tui inserts a trailing + // space and closes the menu without triggering argument completion. Reopen + // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + if ( + textBeforeCursor.endsWith(' ') && + textBeforeCursor.startsWith('/') && + textBeforeCursor.includes(' ') + ) { + trigger(); + } } } diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 9e991ef3bf..e6129ffce5 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -142,6 +142,73 @@ describe('CustomEditor slash argument completion refresh', () => { }); }); +describe('CustomEditor slash command name Tab-accept', () => { + it('reopens subcommand completions after Tab-accepting a slash command name', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'goal', + description: 'Manage goals', + getArgumentCompletions: (prefix) => + prefix === '' + ? [ + { value: 'status', label: 'status' }, + { value: 'pause', label: 'pause' }, + ] + : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/go') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/goal '); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('does not fall back to file completions for a command without subcommands', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'compact', + description: 'Compact context', + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/comp') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/compact '); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + describe('CustomEditor @ mention completion refresh', () => { it('reopens the next directory level after tab-accepting an @ directory', async () => { const editor = makeEditor(); @@ -200,6 +267,21 @@ describe('CustomEditor @ mention completion refresh', () => { }); }); +describe('CustomEditor Tab key handling', () => { + it('does not open autocomplete when Tab is pressed with the dropdown closed', async () => { + const editor = makeEditor(); + const provider = providerReturning([{ value: '@src/file.ts', label: 'file.ts' }]); + editor.setAutocompleteProvider(provider); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + expect(provider.getSuggestions).not.toHaveBeenCalled(); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + describe('CustomEditor slash argument hint', () => { // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, '');