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-shell-mode-slash-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show file path completions when typing `/` in shell mode (`!`).
41 changes: 38 additions & 3 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ interface AutocompleteListFactoryInternals {
createAutocompleteList?: (prefix: string, items: SelectItem[]) => SelectList;
}

interface AutocompleteTriggerInternals {
tryTriggerAutocomplete: (explicitTab?: boolean) => void;
requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void;
}

// Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT
// (dist/components/editor.js); keep in sync when bumping pi-tui.
const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
Expand Down Expand Up @@ -193,6 +198,17 @@ export class CustomEditor extends Editor {
}
return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList);
};

// pi-tui auto-triggers autocomplete for `/` (and letters in a slash
// context) with force:false, which routes through the slash-command
// branch. In bash mode `/` is a path separator, not a command prefix, so
// shadow the trigger to request file path completion (force:true) instead.
// Prompt mode keeps the original force:false behaviour. `tryTriggerAutocomplete`
// is private in pi-tui's typings but a plain prototype method at runtime.
const triggerInternals = this as unknown as AutocompleteTriggerInternals;
triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => {
triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab });
};
}

private expandPasteMarkerAtCursor(): boolean {
Expand Down Expand Up @@ -240,7 +256,7 @@ export class CustomEditor extends Editor {
const firstContentIdx = 1;
const isBash = this.inputMode === 'bash';
const text = this.getText().trimStart();
if (text.startsWith('/')) {
if (text.startsWith('/') && !isBash) {
// Paint only the FIRST editor content line; multi-line slash commands
// are not a thing in practice.
const original = lines[firstContentIdx];
Expand Down Expand Up @@ -280,6 +296,8 @@ export class CustomEditor extends Editor {
}

private computeArgumentHint(): string | undefined {
// Argument hints describe slash commands, which do not exist in bash mode.
if (this.inputMode === 'bash') return undefined;
const text = this.getText();
const match = /^\/(\S+)( ?)$/.exec(text);
if (match === null) return undefined;
Expand Down Expand Up @@ -496,18 +514,35 @@ export class CustomEditor extends Editor {
// 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) {
if (isAtMention) {
trigger();
} else if (this.inputMode === 'bash') {
// In bash mode `/` is a path separator, not a slash command. A bare
// leading `/` is already handled by the tryTriggerAutocomplete shadow
// in the constructor; this branch covers the inline case (e.g. `ls /`,
// `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true
// is required so pi-tui's own slash-command handling is bypassed —
// force:false would let it pop up subcommand completions.
if (textBeforeCursor.trimStart() !== '/') {
editor.requestAutocomplete?.({ force: true, explicitTab: false });
Comment thread
liruifengv marked this conversation as resolved.
}
} else {
const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' ');
if (isSlashArgument) {
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.
// Skipped in bash mode: `/` is a path there, and force:false would let
// pi-tui's own slash-command handling pop up subcommand completions.
if (
this.inputMode !== 'bash' &&
textBeforeCursor.endsWith(' ') &&
textBeforeCursor.startsWith('/') &&
textBeforeCursor.includes(' ')
Expand Down
73 changes: 69 additions & 4 deletions apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class FileMentionProvider implements AutocompleteProvider {
private readonly workDir: string,
private readonly fdPath: string | null,
additionalDirs: readonly string[] = [],
private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt',
) {
this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir)));
// Build an expanded list that includes alias entries so that
Expand Down Expand Up @@ -169,13 +170,26 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}

const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor);
if (slashArgumentSuggestions !== null) {
return slashArgumentSuggestions;
// In bash mode `/` is a path separator, not a slash command. Skip slash
// command argument handling so an absolute path that happens to start with
// a command name (e.g. `/add-dir/...`) completes inside the path instead of
// returning the command's argument completions.
if (this.getInputMode() !== 'bash') {
const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor);
if (slashArgumentSuggestions !== null) {
return slashArgumentSuggestions;
}
}

try {
return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
if (inner === null || this.getInputMode() !== 'bash') {
return inner;
}
// In bash mode `/` is a path separator; hide dot-prefixed entries to
// match the `/add-dir` directory completer (registry.ts skips any name
// starting with `.`). Ordinary prompt-mode path completion is left as-is.
return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) };
Comment thread
liruifengv marked this conversation as resolved.
} catch {
return null;
}
Expand All @@ -188,6 +202,15 @@ export class FileMentionProvider implements AutocompleteProvider {
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
// In bash mode a leading `/` is a path, but pi-tui's applyCompletion
// mistakes it for a slash command (prefix starts with `/`, nothing before
// it, no second `/`) and prepends another `/`, producing e.g.
// `//Applications/ ` with a trailing space that also blocks further
// completion. Handle path completion ourselves so the value replaces the
// prefix verbatim. `@` mentions keep pi-tui's behaviour.
if (this.getInputMode() === 'bash' && prefix.startsWith('/')) {
return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix);
}
return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
}
}
Expand All @@ -204,6 +227,48 @@ export function extractAtPrefix(text: string): string | null {
return text.slice(tokenStart);
}

/**
* Match the `/add-dir` directory completer, which skips every entry whose name
* starts with `.` (see registry.ts). pi-tui's path completer sets `label` to
* the entry basename, with a trailing `/` for directories.
*/
function isDotPrefixedEntry(item: AutocompleteItem): boolean {
const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label;
return name.startsWith('.');
}

/**
* Replace `prefix` with `item.value` verbatim, mirroring pi-tui's file-path
* branch (no trailing space, so a completed directory can be extended with the
* next `/`). Used in bash mode to avoid pi-tui's slash-command branch, which
* would prepend an extra `/` to a bare leading `/` path. For a quoted
* directory value (path contains spaces), the cursor stays inside the closing
* quote so follow-up `/` completion keeps working.
*/
function applyPathCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
const currentLine = lines[cursorLine] ?? '';
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const newLine = beforePrefix + item.value + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith('/');
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset =
isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}

function getFsMentionSuggestions(
workDir: string,
additionalDirs: readonly string[],
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export class KimiTUI {
this.state.appState.workDir,
this.fdPath,
this.state.appState.additionalDirs,
() => this.state.appState.inputMode,
);
this.state.editor.setAutocompleteProvider(provider);

Expand Down
107 changes: 107 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 @@ -29,6 +29,22 @@ function providerReturning(items: AutocompleteItem[]): AutocompleteProvider {
};
}

function providerRecordingForce(items: AutocompleteItem[]): {
provider: AutocompleteProvider;
calls: Array<{ force: boolean | undefined; text: string }>;
} {
const calls: Array<{ force: boolean | undefined; text: string }> = [];
const provider: AutocompleteProvider = {
getSuggestions: vi.fn(async (lines, cursorLine, cursorCol, options) => {
const text = (lines[cursorLine] ?? '').slice(0, cursorCol);
calls.push({ force: options?.force, text });
return { items, prefix: text };
}),
applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })),
};
return { provider, calls };
}

describe('CustomEditor autocomplete Escape handling', () => {
it('escape closes a visible slash command menu without firing app-level escape', async () => {
const editor = makeEditor();
Expand Down Expand Up @@ -356,6 +372,35 @@ describe('CustomEditor slash argument hint', () => {
const plain = editor.render(90).map(stripAnsi).join('\n');
expect(plain).not.toContain('[list] | <path>');
});

it('does not render the argument hint in bash mode', () => {
const editor = makeEditor();
editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']]));
editor.inputMode = 'bash';

for (const char of '/add-dir') {
editor.handleInput(char);
}

const plain = editor.render(90).map(stripAnsi).join('\n');
expect(plain).not.toContain('[list] | <path>');
});

it('does not highlight the slash token in bash mode', () => {
const editor = makeEditor();
editor.inputMode = 'bash';

for (const char of '/add-dir') {
editor.handleInput(char);
}

const contentLine = editor.render(90)[1] ?? '';
const tokenIdx = contentLine.indexOf('/add-dir');
expect(tokenIdx).toBeGreaterThan(-1);
// Prompt mode wraps `/add-dir` in a primary-colour ANSI sequence; in bash
// mode the token is plain text, so the byte right before it is a space.
expect(contentLine[tokenIdx - 1]).toBe(' ');
});
});

describe('CustomEditor slash menu description wrapping', () => {
Expand Down Expand Up @@ -662,3 +707,65 @@ describe('CustomEditor bash mode via paste', () => {
expect(editor.getText()).toBe('');
});
});

describe('CustomEditor bash mode file completion', () => {
it('triggers file completion (force:true) for a leading / in bash mode, not the slash menu', async () => {
const editor = makeEditor();
const { provider, calls } = providerRecordingForce([{ value: 'auto', label: 'auto' }]);
editor.setAutocompleteProvider(provider);
editor.inputMode = 'bash';

editor.handleInput('/');
await flushAutocomplete();

expect(calls).toContainEqual(expect.objectContaining({ force: true, text: '/' }));
expect(editor.isShowingAutocomplete()).toBe(true);
});

it('triggers file completion (force:true) for an inline / in bash mode', async () => {
const editor = makeEditor();
const { provider, calls } = providerRecordingForce([{ value: 'etc', label: 'etc' }]);
editor.setAutocompleteProvider(provider);
editor.inputMode = 'bash';

for (const char of 'ls /') {
editor.handleInput(char);
}
await flushAutocomplete();

expect(calls).toContainEqual(expect.objectContaining({ force: true, text: 'ls /' }));
expect(editor.isShowingAutocomplete()).toBe(true);
});

it('keeps force:false (slash menu) for a leading / in prompt mode', async () => {
const editor = makeEditor();
const { provider, calls } = providerRecordingForce([{ value: 'help', label: 'help' }]);
editor.setAutocompleteProvider(provider);
// inputMode defaults to 'prompt'

editor.handleInput('/');
await flushAutocomplete();

expect(calls).toContainEqual(expect.objectContaining({ force: false, text: '/' }));
expect(editor.isShowingAutocomplete()).toBe(true);
});

it('never falls back to force:false for a slash-shaped command in bash mode', async () => {
const editor = makeEditor();
const { provider, calls } = providerRecordingForce([{ value: 'list', label: 'list' }]);
editor.setAutocompleteProvider(provider);
editor.inputMode = 'bash';

for (const char of '/add-dir ') {
editor.handleInput(char);
}
await new Promise((resolve) => setTimeout(resolve, 30));
await flushAutocomplete();

// A force:false request would let pi-tui's own slash-command handling pop
// up subcommand completions for `/add-dir `. Bash mode must only ever
// request force:true path completion.
expect(calls.length).toBeGreaterThan(0);
expect(calls.every((call) => call.force === true)).toBe(true);
});
});
Loading
Loading