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
5 changes: 5 additions & 0 deletions .changeset/fix-slash-subcommand-after-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show subcommand suggestions after Tab-completing a slash command name.
5 changes: 5 additions & 0 deletions .changeset/fix-tab-file-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the Tab key unexpectedly opening the file completion list.
52 changes: 43 additions & 9 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}

Expand Down
82 changes: 82 additions & 0 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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, '');
Expand Down
Loading