diff --git a/.changeset/fix-shell-mode-slash-completion.md b/.changeset/fix-shell-mode-slash-completion.md new file mode 100644 index 0000000000..9078ab1d20 --- /dev/null +++ b/.changeset/fix-shell-mode-slash-completion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show file path completions when typing `/` in shell mode (`!`). 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 da5126fda8..05bd81b154 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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 = { @@ -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 { @@ -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]; @@ -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; @@ -496,10 +514,24 @@ 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 }); + } + } else { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + if (isSlashArgument) { + trigger(); + } } return; } @@ -507,7 +539,10 @@ export class CustomEditor extends Editor { // 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(' ') diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 4e4b85c2fd..cb9029bb79 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -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 @@ -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)) }; } catch { return null; } @@ -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); } } @@ -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[], diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b8730f18c5..b4b3b39d49 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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); 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 46b44f1e0d..a1f53b8d19 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 @@ -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(); @@ -356,6 +372,35 @@ describe('CustomEditor slash argument hint', () => { const plain = editor.render(90).map(stripAnsi).join('\n'); expect(plain).not.toContain('[list] | '); }); + + it('does not render the argument hint in bash mode', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | ']])); + 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] | '); + }); + + 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', () => { @@ -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); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index dd310d1379..5e2fa6ef3c 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -410,4 +410,167 @@ describe('FileMentionProvider', () => { ); expect(dir.lines[0]).toBe('hey @src/'); }); + + describe('bash-mode path completion dotfile filtering', () => { + it('hides dot-prefixed entries (matching /add-dir) in bash mode', async () => { + mkdirSync(join(workDir, '.hidden')); + mkdirSync(join(workDir, 'visible')); + writeFileSync(join(workDir, '.dotfile'), ''); + writeFileSync(join(workDir, 'normal.txt'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('visible/'); + expect(labels).toContain('normal.txt'); + expect(labels).not.toContain('.hidden/'); + expect(labels).not.toContain('.dotfile'); + }); + + it('keeps dot-prefixed entries in prompt mode', async () => { + mkdirSync(join(workDir, '.hidden')); + writeFileSync(join(workDir, '.dotfile'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('.hidden/'); + expect(labels).toContain('.dotfile'); + }); + }); + + describe('bash-mode path applyCompletion', () => { + it('does not double the leading slash for a bare / path', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: '/Applications/', label: 'Applications/' }, + '/', + ); + expect(result.lines[0]).toBe('/Applications/'); + expect(result.cursorCol).toBe('/Applications/'.length); + }); + + it('replaces the path prefix after a command without a trailing space', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /App'], + 0, + 7, + { value: '/Applications/', label: 'Applications/' }, + '/App', + ); + expect(result.lines[0]).toBe('cd /Applications/'); + expect(result.cursorCol).toBe('cd /Applications/'.length); + }); + + it('keeps the cursor inside the closing quote for a spaced directory', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /tmp/My'], + 0, + 10, + { value: '"/tmp/My Dir/"', label: 'My Dir/' }, + '/tmp/My', + ); + expect(result.lines[0]).toBe('cd "/tmp/My Dir/"'); + // Cursor sits before the closing quote so the next `/` continues inside it. + expect(result.cursorCol).toBe('cd "/tmp/My Dir/'.length); + }); + + it('keeps pi-tui slash-command behaviour in prompt mode', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: 'help', label: 'help' }, + '/', + ); + // pi-tui's slash-command branch: beforePrefix + '/' + value + ' ' + expect(result.lines[0]).toBe('/help '); + }); + }); + + describe('bash-mode slash argument completion suppression', () => { + it('does not invoke slash argument completions for an absolute path in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [ + { value: '/should-not-appear/', label: 'should-not-appear/' }, + ]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + const text = '/add-dir/tmp/'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('should-not-appear/'); + }); + + it('does not invoke slash argument completions for a trailing-space command in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: 'list', label: 'list' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + // `/add-dir ` (trailing space) used to be re-triggered with force:false, + // which let pi-tui's own slash-command handling return subcommand + // completions. Bash mode now only ever triggers force:true path + // completion, so the argument completer must not run. + const text = '/add-dir '; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('list'); + }); + + it('keeps slash argument completion in prompt mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: '/shared/', label: 'shared/' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'prompt', + ); + + const text = '/add-dir /'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: false, + }); + + expect(getArgumentCompletions).toHaveBeenCalled(); + expect(result?.items.map((item) => item.label)).toContain('shared/'); + }); + }); });