summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks')
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.integration.test.ts284
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.test.ts699
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts121
3 files changed, 729 insertions, 375 deletions
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
index 840d2814..d4c66a15 100644
--- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
@@ -16,6 +16,7 @@ import {
SlashCommand,
} from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
+import { useTextBuffer } from '../components/shared/text-buffer.js';
interface MockConfig {
getFileFilteringOptions: () => {
@@ -26,6 +27,19 @@ interface MockConfig {
getFileService: () => FileDiscoveryService | null;
}
+// Helper to create real TextBuffer objects within renderHook
+const useTextBufferForTest = (text: string) => {
+ const cursorOffset = text.length;
+
+ return useTextBuffer({
+ initialText: text,
+ initialCursorOffset: cursorOffset,
+ viewport: { width: 80, height: 20 },
+ isValidPath: () => false,
+ onChange: () => {},
+ });
+};
+
// Mock dependencies
vi.mock('fs/promises');
vi.mock('@google/gemini-cli-core', async () => {
@@ -183,16 +197,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@d',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@d');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
// Wait for async operations to complete
await act(async () => {
@@ -241,16 +255,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
// Wait for async operations to complete
await act(async () => {
@@ -323,16 +337,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@t',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@t');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
// Wait for async operations to complete
await act(async () => {
@@ -362,16 +376,16 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'dist', isDirectory: () => true },
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
- renderHook(() =>
- useCompletion(
- '@d',
+ renderHook(() => {
+ const textBuffer = useTextBufferForTest('@d');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfigNoRecursive,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -390,22 +404,21 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'README.md', isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
undefined,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
- // Without config, should include all files
expect(result.current.suggestions).toHaveLength(3);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
@@ -424,16 +437,16 @@ describe('useCompletion git-aware filtering integration', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -470,16 +483,16 @@ describe('useCompletion git-aware filtering integration', () => {
},
);
- const { result } = renderHook(() =>
- useCompletion(
- '@src/comp',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@src/comp');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -495,16 +508,16 @@ describe('useCompletion git-aware filtering integration', () => {
const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
vi.mocked(glob).mockResolvedValue(globResults);
- const { result } = renderHook(() =>
- useCompletion(
- '@s',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@s');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -530,16 +543,16 @@ describe('useCompletion git-aware filtering integration', () => {
];
vi.mocked(glob).mockResolvedValue(globResults);
- const { result } = renderHook(() =>
- useCompletion(
- '@.',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@.');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
slashCommands,
mockCommandContext,
mockConfig as Config,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -559,15 +572,15 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should suggest top-level command names based on partial input', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/mem',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/mem');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
@@ -578,30 +591,30 @@ describe('useCompletion git-aware filtering integration', () => {
it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed',
async (altName) => {
- const { result } = renderHook(() =>
- useCompletion(
- altName,
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest(altName);
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
},
);
it('should suggest commands based on partial altNames matches', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/usag', // part of the word "usage"
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([
{
@@ -613,15 +626,15 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should suggest sub-command names for a parent command', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory a',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory a');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([
{ label: 'add', value: 'add', description: 'Add to memory' },
@@ -629,15 +642,15 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory ');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
@@ -652,8 +665,9 @@ describe('useCompletion git-aware filtering integration', () => {
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
const mockCompletionFn = vi
.fn()
- .mockImplementation(async (context: CommandContext, partialArg: string) =>
- availableTags.filter((tag) => tag.startsWith(partialArg)),
+ .mockImplementation(
+ async (_context: CommandContext, partialArg: string) =>
+ availableTags.filter((tag) => tag.startsWith(partialArg)),
);
const mockCommandsWithFiltering = JSON.parse(
@@ -678,15 +692,15 @@ describe('useCompletion git-aware filtering integration', () => {
resumeCmd.completion = mockCompletionFn;
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume my-ch',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume my-ch');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockCommandsWithFiltering,
mockCommandContext,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -701,45 +715,45 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/clear ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/clear ');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should not provide suggestions for an unknown command', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/unknown-command',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/unknown-command');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory', // Note: no trailing space
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
// Assert that suggestions for sub-commands are shown immediately
expect(result.current.suggestions).toHaveLength(2);
@@ -753,15 +767,15 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/clear', // No trailing space
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/clear'); // No trailing space
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
@@ -787,15 +801,15 @@ describe('useCompletion git-aware filtering integration', () => {
}
resumeCommand.completion = mockCompletionFn;
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume ', // Trailing space, no partial argument
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
isolatedMockCommands,
mockCommandContext,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -807,15 +821,15 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should suggest all top-level commands for the root slash', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -824,15 +838,15 @@ describe('useCompletion git-aware filtering integration', () => {
});
it('should provide no suggestions for an invalid sub-command', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory dothisnow',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory dothisnow');
+ return useCompletion(
+ textBuffer,
'/test/cwd',
- true,
mockSlashCommands,
mockCommandContext,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts
index 19671de4..96e8f156 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.test.ts
@@ -18,6 +18,20 @@ import {
SlashCommand,
} from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
+import { useTextBuffer } from '../components/shared/text-buffer.js';
+
+// Helper to create real TextBuffer objects within renderHook
+const useTextBufferForTest = (text: string) => {
+ const cursorOffset = text.length;
+
+ return useTextBuffer({
+ initialText: text,
+ initialCursorOffset: cursorOffset,
+ viewport: { width: 80, height: 20 },
+ isValidPath: () => false,
+ onChange: () => {},
+ });
+};
// Mock dependencies
vi.mock('fs/promises');
@@ -140,16 +154,16 @@ describe('useCompletion', () => {
describe('Hook initialization and state', () => {
it('should initialize with default state', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- false,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toEqual([]);
expect(result.current.activeSuggestionIndex).toBe(-1);
@@ -158,21 +172,23 @@ describe('useCompletion', () => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
- it('should reset state when isActive becomes false', () => {
+ it('should reset state when query becomes inactive', () => {
const { result, rerender } = renderHook(
- ({ isActive }) =>
- useCompletion(
- '/help',
+ ({ text }) => {
+ const textBuffer = useTextBufferForTest(text);
+ return useCompletion(
+ textBuffer,
testCwd,
- isActive,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- { initialProps: { isActive: true } },
+ );
+ },
+ { initialProps: { text: '/help' } },
);
- rerender({ isActive: false });
+ // Inactive because of the leading space
+ rerender({ text: ' /help' });
expect(result.current.suggestions).toEqual([]);
expect(result.current.activeSuggestionIndex).toBe(-1);
@@ -182,16 +198,16 @@ describe('useCompletion', () => {
});
it('should provide required functions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(typeof result.current.setActiveSuggestionIndex).toBe('function');
expect(typeof result.current.setShowSuggestions).toBe('function');
@@ -203,16 +219,16 @@ describe('useCompletion', () => {
describe('resetCompletionState', () => {
it('should reset all state to default values', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/help',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/help');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
act(() => {
result.current.setActiveSuggestionIndex(5);
@@ -233,16 +249,16 @@ describe('useCompletion', () => {
describe('Navigation functions', () => {
it('should handle navigateUp with no suggestions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
act(() => {
result.current.navigateUp();
@@ -252,16 +268,16 @@ describe('useCompletion', () => {
});
it('should handle navigateDown with no suggestions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
act(() => {
result.current.navigateDown();
@@ -271,16 +287,16 @@ describe('useCompletion', () => {
});
it('should navigate up through suggestions with wrap-around', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/h',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/h');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(1);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -293,16 +309,16 @@ describe('useCompletion', () => {
});
it('should navigate down through suggestions with wrap-around', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/h',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/h');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(1);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -315,16 +331,16 @@ describe('useCompletion', () => {
});
it('should handle navigation with multiple suggestions', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(5);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -363,16 +379,16 @@ describe('useCompletion', () => {
action: vi.fn(),
}));
- const { result } = renderHook(() =>
- useCompletion(
- '/command',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/command');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
largeMockCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions.length).toBe(15);
expect(result.current.activeSuggestionIndex).toBe(0);
@@ -389,16 +405,16 @@ describe('useCompletion', () => {
describe('Slash command completion', () => {
it('should show all commands for root slash', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(5);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -409,16 +425,16 @@ describe('useCompletion', () => {
});
it('should filter commands by prefix', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/h',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/h');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('help');
@@ -428,64 +444,64 @@ describe('useCompletion', () => {
it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed',
(altName) => {
- const { result } = renderHook(() =>
- useCompletion(
- altName,
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest(altName);
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
},
);
it('should suggest commands based on partial altNames matches', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/usag', // part of the word "usage"
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('stats');
});
it('should not show suggestions for exact leaf command match', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/clear',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/clear');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should show sub-commands for parent commands', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -494,16 +510,16 @@ describe('useCompletion', () => {
});
it('should show all sub-commands after parent command with space', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory ');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -512,32 +528,32 @@ describe('useCompletion', () => {
});
it('should filter sub-commands by prefix', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/memory a',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/memory a');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('add');
});
it('should handle unknown command gracefully', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/unknown',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/unknown');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
@@ -558,16 +574,16 @@ describe('useCompletion', () => {
resumeCommand.completion = completionFn;
}
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume ');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithCompletion,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -594,16 +610,16 @@ describe('useCompletion', () => {
resumeCommand.completion = completionFn;
}
- renderHook(() =>
- useCompletion(
- '/chat resume ar',
+ renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume ar');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithCompletion,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -625,16 +641,16 @@ describe('useCompletion', () => {
resumeCommand.completion = completionFn;
}
- const { result } = renderHook(() =>
- useCompletion(
- '/chat resume ',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/chat resume ');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithCompletion,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -673,32 +689,32 @@ describe('useCompletion', () => {
});
it('should suggest a namespaced command based on a partial match', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/git:co',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/git:co');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithNamespaces,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('git:commit');
});
it('should suggest all commands within a namespace when the namespace prefix is typed', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/git:',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/git:');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithNamespaces,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
@@ -711,16 +727,16 @@ describe('useCompletion', () => {
});
it('should not provide suggestions if the namespaced command is a perfect leaf match', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '/git:commit',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('/git:commit');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
commandsWithNamespaces,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.showSuggestions).toBe(false);
expect(result.current.suggestions).toHaveLength(0);
@@ -738,16 +754,16 @@ describe('useCompletion', () => {
});
it('should show file completions for @ prefix', async () => {
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -766,16 +782,16 @@ describe('useCompletion', () => {
`${testCwd}/file2.js`,
]);
- const { result } = renderHook(() =>
- useCompletion(
- '@file',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@file');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -791,16 +807,16 @@ describe('useCompletion', () => {
// Mock for recursive search since enableRecursiveFileSearch is true
vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]);
- const { result } = renderHook(() =>
- useCompletion(
- '@.',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@.');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -815,16 +831,16 @@ describe('useCompletion', () => {
(enoentError as Error & { code: string }).code = 'ENOENT';
vi.mocked(fs.readdir).mockRejectedValue(enoentError);
- const { result } = renderHook(() =>
- useCompletion(
- '@nonexistent',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@nonexistent');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -840,16 +856,16 @@ describe('useCompletion', () => {
.mockImplementation(() => {});
vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied'));
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -870,21 +886,22 @@ describe('useCompletion', () => {
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
const { rerender } = renderHook(
- ({ query }) =>
- useCompletion(
- query,
+ ({ text }) => {
+ const textBuffer = useTextBufferForTest(text);
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- { initialProps: { query: '@f' } },
+ );
+ },
+ { initialProps: { text: '@f' } },
);
- rerender({ query: '@fi' });
- rerender({ query: '@fil' });
- rerender({ query: '@file' });
+ rerender({ text: '@fi' });
+ rerender({ text: '@fil' });
+ rerender({ text: '@file' });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -896,48 +913,48 @@ describe('useCompletion', () => {
describe('Query handling edge cases', () => {
it('should handle empty query', () => {
- const { result } = renderHook(() =>
- useCompletion(
- '',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should handle query without slash or @', () => {
- const { result } = renderHook(() =>
- useCompletion(
- 'regular text',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('regular text');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
it('should handle query with whitespace', () => {
- const { result } = renderHook(() =>
- useCompletion(
- ' /hel',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest(' /hel');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('help');
@@ -947,16 +964,16 @@ describe('useCompletion', () => {
// Mock for recursive search since enableRecursiveFileSearch is true
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
- const { result } = renderHook(() =>
- useCompletion(
- 'some text @',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('some text @');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
// Wait for completion
await act(async () => {
@@ -983,16 +1000,16 @@ describe('useCompletion', () => {
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
- const { result } = renderHook(() =>
- useCompletion(
- '@comp',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@comp');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -1015,22 +1032,238 @@ describe('useCompletion', () => {
});
});
+ describe('handleAutocomplete', () => {
+ it('should complete a partial command', () => {
+ // Create a mock buffer that we can spy on directly
+ const mockBuffer = {
+ text: '/mem',
+ lines: ['/mem'],
+ cursor: [0, 4],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['/mem'],
+ viewportVisualLines: ['/mem'],
+ visualCursor: [0, 4],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'memory',
+ ]);
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(mockBuffer.setText).toHaveBeenCalledWith('/memory');
+ });
+
+ it('should append a sub-command when the parent is complete', () => {
+ const mockBuffer = {
+ text: '/memory ',
+ lines: ['/memory '],
+ cursor: [0, 8],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['/memory '],
+ viewportVisualLines: ['/memory '],
+ visualCursor: [0, 8],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ // Suggestions are populated by useEffect
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'show',
+ 'add',
+ ]);
+
+ act(() => {
+ result.current.handleAutocomplete(1); // index 1 is 'add'
+ });
+
+ expect(mockBuffer.setText).toHaveBeenCalledWith('/memory add');
+ });
+
+ it('should complete a command with an alternative name', () => {
+ const mockBuffer = {
+ text: '/?',
+ lines: ['/?'],
+ cursor: [0, 2],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['/?'],
+ viewportVisualLines: ['/?'],
+ visualCursor: [0, 2],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ result.current.suggestions.push({
+ label: 'help',
+ value: 'help',
+ description: 'Show help',
+ });
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(mockBuffer.setText).toHaveBeenCalledWith('/help');
+ });
+
+ it('should complete a file path', async () => {
+ const mockBuffer = {
+ text: '@src/fi',
+ lines: ['@src/fi'],
+ cursor: [0, 7],
+ preferredCol: null,
+ selectionAnchor: null,
+ allVisualLines: ['@src/fi'],
+ viewportVisualLines: ['@src/fi'],
+ visualCursor: [0, 7],
+ visualScrollRow: 0,
+ setText: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ backspace: vi.fn(),
+ del: vi.fn(),
+ move: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ replaceRange: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ moveToOffset: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ handleInput: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ mockBuffer,
+ testCwd,
+ mockSlashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ result.current.suggestions.push({
+ label: 'file1.txt',
+ value: 'file1.txt',
+ });
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
+ 5, // after '@src/'
+ mockBuffer.text.length,
+ 'file1.txt',
+ );
+ });
+ });
+
describe('Config and FileDiscoveryService integration', () => {
it('should work without config', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'file1.txt', isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
undefined,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
@@ -1050,16 +1283,16 @@ describe('useCompletion', () => {
(path: string) => path.includes('.log'),
);
- const { result } = renderHook(() =>
- useCompletion(
- '@',
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@');
+ return useCompletion(
+ textBuffer,
testCwd,
- true,
mockSlashCommands,
mockCommandContext,
mockConfig,
- ),
- );
+ );
+ });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index aacc111d..f4ebfac3 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
@@ -22,6 +22,9 @@ import {
Suggestion,
} from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
+import { TextBuffer } from '../components/shared/text-buffer.js';
+import { isSlashCommand } from '../utils/commandUtils.js';
+import { toCodePoints } from '../utils/textUtils.js';
export interface UseCompletionReturn {
suggestions: Suggestion[];
@@ -35,12 +38,12 @@ export interface UseCompletionReturn {
resetCompletionState: () => void;
navigateUp: () => void;
navigateDown: () => void;
+ handleAutocomplete: (indexToUse: number) => void;
}
export function useCompletion(
- query: string,
+ buffer: TextBuffer,
cwd: string,
- isActive: boolean,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
config?: Config,
@@ -122,13 +125,45 @@ export function useCompletion(
});
}, [suggestions.length]);
+ // Check if cursor is after @ or / without unescaped spaces
+ const isActive = useMemo(() => {
+ if (isSlashCommand(buffer.text.trim())) {
+ return true;
+ }
+
+ // For other completions like '@', we search backwards from the cursor.
+ const [row, col] = buffer.cursor;
+ const currentLine = buffer.lines[row] || '';
+ const codePoints = toCodePoints(currentLine);
+
+ for (let i = col - 1; i >= 0; i--) {
+ const char = codePoints[i];
+
+ if (char === ' ') {
+ // Check for unescaped spaces.
+ let backslashCount = 0;
+ for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
+ backslashCount++;
+ }
+ if (backslashCount % 2 === 0) {
+ return false; // Inactive on unescaped space.
+ }
+ } else if (char === '@') {
+ // Active if we find an '@' before any unescaped space.
+ return true;
+ }
+ }
+
+ return false;
+ }, [buffer.text, buffer.cursor, buffer.lines]);
+
useEffect(() => {
if (!isActive) {
resetCompletionState();
return;
}
- const trimmedQuery = query.trimStart();
+ const trimmedQuery = buffer.text.trimStart();
if (trimmedQuery.startsWith('/')) {
// Always reset perfect match at the beginning of processing.
@@ -275,13 +310,13 @@ export function useCompletion(
}
// Handle At Command Completion
- const atIndex = query.lastIndexOf('@');
+ const atIndex = buffer.text.lastIndexOf('@');
if (atIndex === -1) {
resetCompletionState();
return;
}
- const partialPath = query.substring(atIndex + 1);
+ const partialPath = buffer.text.substring(atIndex + 1);
const lastSlashIndex = partialPath.lastIndexOf('/');
const baseDirRelative =
lastSlashIndex === -1
@@ -545,7 +580,7 @@ export function useCompletion(
clearTimeout(debounceTimeout);
};
}, [
- query,
+ buffer.text,
cwd,
isActive,
resetCompletionState,
@@ -554,6 +589,77 @@ export function useCompletion(
config,
]);
+ const handleAutocomplete = useCallback(
+ (indexToUse: number) => {
+ if (indexToUse < 0 || indexToUse >= suggestions.length) {
+ return;
+ }
+ const query = buffer.text;
+ const suggestion = suggestions[indexToUse].value;
+
+ if (query.trimStart().startsWith('/')) {
+ const hasTrailingSpace = query.endsWith(' ');
+ const parts = query
+ .trimStart()
+ .substring(1)
+ .split(/\s+/)
+ .filter(Boolean);
+
+ let isParentPath = false;
+ // If there's no trailing space, we need to check if the current query
+ // is already a complete path to a parent command.
+ if (!hasTrailingSpace) {
+ let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const found: SlashCommand | undefined = currentLevel?.find(
+ (cmd) => cmd.name === part || cmd.altNames?.includes(part),
+ );
+
+ if (found) {
+ if (i === parts.length - 1 && found.subCommands) {
+ isParentPath = true;
+ }
+ currentLevel = found.subCommands as
+ | readonly SlashCommand[]
+ | undefined;
+ } else {
+ // Path is invalid, so it can't be a parent path.
+ currentLevel = undefined;
+ break;
+ }
+ }
+ }
+
+ // Determine the base path of the command.
+ // - If there's a trailing space, the whole command is the base.
+ // - If it's a known parent path, the whole command is the base.
+ // - Otherwise, the base is everything EXCEPT the last partial part.
+ const basePath =
+ hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
+ const newValue = `/${[...basePath, suggestion].join(' ')}`;
+
+ buffer.setText(newValue);
+ } else {
+ const atIndex = query.lastIndexOf('@');
+ if (atIndex === -1) return;
+ const pathPart = query.substring(atIndex + 1);
+ const lastSlashIndexInPath = pathPart.lastIndexOf('/');
+ let autoCompleteStartIndex = atIndex + 1;
+ if (lastSlashIndexInPath !== -1) {
+ autoCompleteStartIndex += lastSlashIndexInPath + 1;
+ }
+ buffer.replaceRangeByOffset(
+ autoCompleteStartIndex,
+ buffer.text.length,
+ suggestion,
+ );
+ }
+ resetCompletionState();
+ },
+ [resetCompletionState, buffer, suggestions, slashCommands],
+ );
+
return {
suggestions,
activeSuggestionIndex,
@@ -566,5 +672,6 @@ export function useCompletion(
resetCompletionState,
navigateUp,
navigateDown,
+ handleAutocomplete,
};
}