Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cd443a8
feat(cli): do not append trailing space for directory completions (#4…
May 18, 2026
8ed8860
fix(cli): append trailing / to directory completions for deeper navig…
May 19, 2026
13b3b35
fix(cli): propagate isDirectory and fix JSDoc comment
May 19, 2026
ecd8047
fix(cli): add comprehensive isDirectory propagation tests
May 19, 2026
f0be4c2
fix(cli): address wenshao feedback - lint rules, real test, cross-pla…
May 19, 2026
9b6e7d8
fix(cli): remove unused import and fix Windows path separator in tests
May 20, 2026
eb1a7e5
Apply suggestion from @wenshao
dykebo May 20, 2026
978d874
Update packages/cli/src/ui/commands/directoryCommand.test.tsx
dykebo May 20, 2026
79c6ce0
Update packages/cli/src/ui/commands/directoryCommand.tsx
dykebo May 20, 2026
ab145c6
Update packages/cli/src/ui/commands/directoryCommand.tsx
dykebo May 20, 2026
7492c81
fix(cli): normalize isDirectory to explicit boolean in toSuggestion
May 20, 2026
c1c50d0
Update packages/cli/src/ui/hooks/useSlashCompletion.ts
dykebo May 20, 2026
62ca7cd
chore: remove accidentally committed pr_body.md
May 20, 2026
f8cc432
chore: add pr_body.md to .gitignore
May 20, 2026
0ae90dd
fix(cli): remove duplicate .slice and orphaned test code from directo…
May 20, 2026
1fb292c
fix(cli): only suppress trailing space for dir completions at end-of-…
May 20, 2026
eb30a8a
docs(cli): document crawler path separator dependency for isDirectory…
May 20, 2026
05bb656
test(cli): add mid-line directory completion test
May 20, 2026
862cefb
Update packages/cli/src/ui/hooks/useCommandCompletion.test.ts
dykebo May 21, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ bundle
junit.xml
packages/*/coverage/

# PR body draft
pr_body.md

# Generated files
packages/cli/src/generated/
packages/core/src/generated/
Expand Down
120 changes: 119 additions & 1 deletion packages/cli/src/ui/commands/directoryCommand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js';
import {
directoryCommand,
expandHomeDir,
getDirPathCompletions,
} from './directoryCommand.js';
import type { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import type { CommandContext } from './types.js';
import { MessageType } from '../types.js';
import { SettingScope } from '../../config/settings.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';

describe('directoryCommand', () => {
let mockContext: CommandContext;
Expand Down Expand Up @@ -323,3 +328,116 @@ describe('directoryCommand', () => {
);
});
});

describe('getDirPathCompletions', () => {
let tempTestDir = '';

beforeEach(() => {
// Clean up any previous test runs
if (tempTestDir) {
try {
fs.rmSync(tempTestDir, { recursive: true, force: true });
} catch (err) {
// ignore cleanup errors
void err;
}
}

tempTestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-dir-test-'));
// Create a nested directory structure: root/sub1, root/sub2, root/sub1/deep
fs.mkdirSync(tempTestDir, { recursive: true });
fs.mkdirSync(path.join(tempTestDir, 'sub1'), { recursive: true });
fs.mkdirSync(path.join(tempTestDir, 'sub2'), { recursive: true });
fs.mkdirSync(path.join(tempTestDir, 'sub1', 'deep'), { recursive: true });
// Add some non-directory files (should be filtered out)
fs.writeFileSync(path.join(tempTestDir, 'file.txt'), '');
fs.writeFileSync(
path.join(tempTestDir, 'sub1', 'nested.txt'),
'',
);
});

afterAll(() => {
// Cleanup after all tests
if (tempTestDir) {
try {
fs.rmSync(tempTestDir, { recursive: true, force: true });
} catch (err) {
// ignore cleanup errors
void err;
}
}
});

describe('directory completions should include isDirectory flag', () => {
it('should return suggestions with isDirectory: true and trailing /', () => {
// Use "/" suffix so getDirPathCompletions searches INSIDE the directory
const results = getDirPathCompletions(`${tempTestDir}/`);

expect(results.length).toBeGreaterThan(0);

// Each suggestion should be a CommandCompletionItem with isDirectory: true
results.forEach((suggestion) => {
expect(suggestion.value).toBeDefined();
expect(suggestion.isDirectory).toBe(true);

// Directory values should end with path separator for continued navigation
expect(suggestion.value.endsWith(path.sep)).toBe(true);

// Should match one of our created directories
const dirNameWithoutSlash = suggestion.value.slice(0, -1);
const basename = path.basename(dirNameWithoutSlash);
expect(['sub1', 'sub2'].includes(basename)).toBe(true);
});
});

it('should filter by prefix while preserving isDirectory flag', () => {
const results = getDirPathCompletions(`${tempTestDir}/su`);

expect(results.length).toBeGreaterThan(0);

// Only directories starting with "su" should be returned
results.forEach((suggestion) => {
expect(suggestion.isDirectory).toBe(true);
const sepRe = path.sep === '\\' ? '\\\\' : path.sep;
expect(suggestion.value).toMatch(new RegExp(`${sepRe}su.+$`));
// Only top-level directories matching the prefix are returned
const basename = path.basename(suggestion.value.slice(0, -1));
expect(basename).toMatch(/^su/);
const dirname = path.dirname(suggestion.value);
expect(dirname).toContain(tempTestDir);
});
});

it('should support comma-separated paths with isDirectory flag on last segment', () => {
const multiPath = `${tempTestDir}, ${tempTestDir}/`;
const results = getDirPathCompletions(multiPath);

expect(results.length).toBeGreaterThan(0);

// Results should start with the prefix from first part
results.forEach((suggestion) => {
expect(suggestion.isDirectory).toBe(true);
expect(suggestion.value.startsWith(`${tempTestDir}`)).toBe(true);
expect(suggestion.value.endsWith(path.sep)).toBe(true);
});
});

it('should handle deeply nested directories with isDirectory flag', () => {
// Navigate into sub1
const deepResults = getDirPathCompletions(`${tempTestDir}/sub1/`);

expect(deepResults.length).toBeGreaterThan(0);

// Only directories inside sub1 should be returned
deepResults.forEach((suggestion) => {
expect(suggestion.isDirectory).toBe(true);
expect(suggestion.value).toContain('sub1');
expect(suggestion.value.endsWith(path.sep)).toBe(true);
// The nested 'deep' directory should be in the results
const basename = path.basename(suggestion.value.slice(0, -1));
expect(basename).toBe('deep');
});
});
});
});
9 changes: 6 additions & 3 deletions packages/cli/src/ui/commands/directoryCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { SlashCommand, CommandContext } from './types.js';
import type { SlashCommand, CommandContext, CommandCompletionItem } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as fs from 'node:fs';
Expand Down Expand Up @@ -58,7 +58,7 @@ function findExistingWorkspaceDirectory(
* Returns directory path completions for the given partial argument.
* Supports comma-separated paths by completing only the last segment.
*/
export function getDirPathCompletions(partialArg: string): string[] {
export function getDirPathCompletions(partialArg: string): CommandCompletionItem[] {
const lastComma = partialArg.lastIndexOf(',');
const prefix = lastComma >= 0 ? partialArg.substring(0, lastComma + 1) : '';
const partial =
Expand All @@ -85,7 +85,10 @@ export function getDirPathCompletions(partialArg: string): string[] {
e.name.startsWith(namePrefix) &&
!e.name.startsWith('.'),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The !e.name.startsWith('.') filter (hiding hidden directories) has no test. If someone removes or inverts this filter, no test fails and directories like .git would start appearing in tab completions.

Suggested fix: In beforeEach, create a .hidden directory in tempTestDir, then in existing tests verify it's excluded:

const names = results.map((r) => path.basename(r.value.slice(0, -1)));
expect(names).not.toContain('.hidden');

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

.map((e) => prefix + path.join(searchDir, e.name))
.map((e) => ({
value: prefix + path.join(searchDir, e.name) + path.sep,
isDirectory: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] getDirPathCompletions returns directory values without a trailing /, which prevents deeper directory navigation via /directory add.

After tab-completion, the buffer becomes e.g. src/components (no trailing space due to isDirectory: true, but also no /). On the next tab, getDirPathCompletions('src/components') computes searchDir='src' and namePrefix='components', matching the same directory again β€” creating an infinite tab-completion loop.

The function already handles trailing / in its input (lines 75-77 via endsWithSep), so appending / to the return values would fix deeper navigation:

Suggested change
isDirectory: true,
.map((e) => ({
value: prefix + path.join(searchDir, e.name) + '/',
isDirectory: true,
}))

β€” qwen-latest-series-invite-beta-v28 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, thx for your [Critical]

}))
.slice(0, 8);
} catch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Build is broken β€” duplicate .slice(0, 8); and orphaned test code in production source.

Line 94 is a duplicate of line 93 (.slice(0, 8);). The semicolon on line 93 terminates the method chain, making line 94 an orphaned expression starting with . β€” a syntax error. Additionally, lines 99-103 contain test code fragments (expect(...), });) accidentally pasted from directoryCommand.test.tsx, plus an unreachable return []; and an extra }.

tsc reports 4 errors: TS1128: Declaration or statement expected at lines 93, 99, 101, 102.

This appears to be a merge/rebase artifact from the previous fix attempt.

Suggested change
} catch {
.slice(0, 8);
} catch {
return [];
}

Also delete lines 99-103 (the orphaned test code and extra }/return []; after the catch block).

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

return [];
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export interface CommandCompletionItem {
value: string;
label?: string;
description?: string;
/** Whether the completion represents a directory path. When true, handleAutocomplete should NOT append a trailing space so the user can continue tab-completing deeper into the directory tree. */
isDirectory?: boolean;
}

// The standardized contract for any command in the system.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/components/SuggestionsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface Suggestion {
matchedAlias?: string;
supportedModes?: ExecutionMode[];
modelInvocable?: boolean;
/** Whether the suggestion represents a directory path. When true, handleAutocomplete should NOT append a trailing space so the user can continue tab-completing deeper into the directory tree. */
isDirectory?: boolean;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/ui/hooks/useAtCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ describe('useAtCompletion', () => {
'dir/',
'file.txt',
]);
// Verify isDirectory flag

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] These new isDirectory assertions are unreachable dead code in their current form.

The host test fails at line 142 (expect(...suggestions).toEqual(['dir/', 'file.txt'])) because the file-search crawler excludes the empty dir/ from results β€” a pre-existing issue unrelated to this PR. The .toEqual throws before execution ever reaches lines 146-154.

Impact: isDirectory: p.endsWith('/') at useAtCompletion.ts:217 β€” one of the two main directory-completion entry points β€” has zero effective test coverage. A regression in the / vs path.sep convention would not be caught.

Suggested fix: Add a self-contained test that mocks the file search results directly (similar to how useCommandCompletion.test.ts mocks useAtCompletion), bypassing the broken crawler:

it('should set isDirectory based on trailing slash in file search results', async () => {
  // Mock fileSearch to return ['dir/', 'file.txt']
  // Assert isDirectory is true for 'dir/' and false for 'file.txt'
});

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

const dirSuggestion = result.current.suggestions.find(
(s) => s.value === 'dir/',
);
const fileSuggestion = result.current.suggestions.find(
(s) => s.value === 'file.txt',
);
expect(dirSuggestion?.isDirectory).toBe(true);
expect(fileSuggestion?.isDirectory).toBe(false);
});
});

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/hooks/useAtCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,13 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
return;
}

// isDirectory relies on crawler.ts in @qwen-code/qwen-code-core
// always normalizing paths with posix '/' via fdir.withPathSeparator('/').
// If the crawler ever switches to path.sep, this check must be updated.
const suggestions = results.map((p) => ({
label: p,
value: escapePath(p),
isDirectory: p.endsWith('/'),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isDirectory normalization is inconsistent across completion paths. Here, p.endsWith('/') always produces an explicit boolean (false for files). But toSuggestion in useSlashCompletion.ts:524 propagates item.isDirectory as-is, which is undefined when the CommandCompletionItem omits the optional field.

The sole consumer (handleAutocomplete) uses !isDirectory which treats false and undefined identically, so there's no user-visible bug today. However, the three-state boolean (true | false | undefined) is a latent trap for any future code that distinguishes them.

Consider normalizing to an explicit boolean in one place β€” e.g., isDirectory: item.isDirectory ?? false in toSuggestion, or only setting the field when true in both paths.

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] p.endsWith('/') relies on an implicit coupling: the file-search crawler in @qwen-code/qwen-code-core normalizes all paths to posix separators (fdir.withPathSeparator('/') in crawler.ts). No shared constant or documentation links these two codebases (different packages).

If someone changes the crawler to use path.sep for cross-platform support, isDirectory silently becomes false for all directories on Windows, regressing the trailing-space fix with no error or log.

Suggested fix: add a comment documenting this dependency, or extract a shared constant (e.g., CRAWLER_PATH_SEPARATOR = '/') in @qwen-code/qwen-code-core imported by both locations.

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

}));
dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions });
} catch (error) {
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/ui/hooks/useCommandCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,37 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src/file1.txt ');
});

it('should not append trailing space for directory completions', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new test only covers directory completion at end-of-line (@src/com). There is no test for directory completion when the cursor is mid-line (e.g., @src/com is a dir with cursor after m). The interaction between isDirectory and charAfterCompletion is untested.

Suggested fix: add a test mirroring the existing "should complete a file path when cursor is not at the end of the line" test (line 586), but with a directory suggestion:

it('should not append trailing space for directory completions when cursor is mid-line', async () => {
  const text = '@src/com is a dir';
  // ... setup with isDirectory: true suggestion ...
  expect(result.current.textBuffer.text).toBe('@src/components/ is a dir');
});

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

setupMocks({
atSuggestions: [
{ label: 'src/components/', value: 'src/components/', isDirectory: true },
],
});

const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('@src/com');
const completion = useCommandCompletion(
textBuffer,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});

await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
});

act(() => {
result.current.handleAutocomplete(0);
});

expect(result.current.textBuffer.text).toBe('@src/components/');
});

it('should complete a file path when cursor is not at the end of the line', async () => {
const text = '@src/fi is a good file';
const cursorOffset = 7; // after "i"
Expand Down Expand Up @@ -585,6 +616,46 @@ describe('useCommandCompletion', () => {
'@src/file1.txt is a good file',
);
});

it('should preserve existing space after directory completions at mid-line cursor', async () => {
const text = '@src/com is a dir';
const cursorOffset = 8; // after "m"

setupMocks({
atSuggestions: [
{
label: 'src/components/',
value: 'src/components/',
isDirectory: true,
},
],
});

const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(text, cursorOffset);
const completion = useCommandCompletion(
textBuffer,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});

await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
});

act(() => {
result.current.handleAutocomplete(0);
});

expect(result.current.textBuffer.text).toBe(
'@src/components/ is a dir',
);
});
});

describe('argument hint ghost text', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ui/hooks/useCommandCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ export function useCommandCompletion(

const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
const charAfterCompletion = lineCodePoints[end];
if (charAfterCompletion !== ' ') {
const isDirectory = suggestions[indexToUse].isDirectory;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The condition !isDirectory suppresses the trailing space unconditionally when isDirectory is true, including when the cursor is mid-line. If the user has text after the cursor (e.g., @src/comp|something), the directory completion merges directly with the following text (producing @src/components/something).

Consider only suppressing the space when the cursor is at end-of-line:

if (charAfterCompletion !== ' ' && !(isDirectory && !charAfterCompletion)) {

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

if (charAfterCompletion !== ' ' && !(isDirectory && !charAfterCompletion)) {
suggestionText += ' ';
}

Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/ui/hooks/useSlashCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,4 +1122,39 @@ describe('useSlashCompletion', () => {
expect(mockSetIsLoadingSuggestions).not.toHaveBeenCalled();
expect(mockSetIsPerfectMatch).not.toHaveBeenCalled();
});

describe('isDirectory propagation', () => {
it('should propagate isDirectory from CommandCompletionItem to Suggestion', async () => {
const mockCompletionFn = vi.fn().mockResolvedValue([
{ value: '/tmp/workspace/', isDirectory: true },
{ value: '/tmp/file.txt' },
]);

const slashCommands = [
createTestCommand({
name: 'dir',
description: 'test',
completion: mockCompletionFn,
}),
];

const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/dir ',
slashCommands,
mockCommandContext,
),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The change isDirectory: item.isDirectory ?? false in toSuggestion() (useSlashCompletion.ts:524) adds isDirectory: false to every suggestion object, including non-directory completions. This breaks the existing test at useSlashCompletion.test.ts:828 which uses .toEqual() with bare object literals that do not include isDirectory:

expect(result.current.suggestions).toEqual([
  { label: 'pdf', value: 'pdf', description: 'Create PDF documents' },
  { label: 'xlsx', value: 'xlsx', description: 'Work with spreadsheets' },
]);

vitest .toEqual() performs strict deep equality β€” the extra isDirectory: false field causes a mismatch.

Fix: either update the test at line 828 to include isDirectory: false in both expected objects, or change toSuggestion() to only include the field when truthy:

...(item.isDirectory ? { isDirectory: true } : {}),

β€” qwen-latest-series-invite-beta-v34 via Qwen Code /review

);

await waitFor(() => {
expect(result.current.suggestions.length).toBe(2);
});

// First suggestion (directory) should have isDirectory: true
expect(result.current.suggestions[0].isDirectory).toBe(true);
// Second suggestion (file) should NOT have isDirectory flag
expect(result.current.suggestions[1].isDirectory).toBeFalsy();
});
});
});
1 change: 1 addition & 0 deletions packages/cli/src/ui/hooks/useSlashCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ function toSuggestion(item: string | CommandCompletionItem): Suggestion | null {
label: item.label ?? item.value,
value: item.value,
description: item.description,
...(item.isDirectory !== undefined && { isDirectory: item.isDirectory }),
};
}

Expand Down
Loading