diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index bb503f3074..7c64ff0ca8 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -189,6 +189,7 @@ export const App: React.FC = () => { description: cmd.description, type: 'command' as const, group: 'Slash Commands', + value: cmd.name, }), ); @@ -518,9 +519,11 @@ export const App: React.FC = () => { setAskUserQuestionRequest(null); }, [vscode]); - // Handle completion selection + // Handle completion selection. + // When fillOnly is true (Tab), slash commands are inserted into the input + // instead of being sent immediately, so users can append arguments. const handleCompletionSelect = useCallback( - (item: CompletionItem) => { + (item: CompletionItem, fillOnly?: boolean) => { // Handle completion selection by inserting the value into the input field const inputElement = inputFieldRef.current; if (!inputElement) { @@ -593,13 +596,13 @@ export const App: React.FC = () => { } }; - // Handle special commands by id if (itemId === 'login') { clearTriggerText(); vscode.postMessage({ type: 'login', data: {} }); completion.closeCompletion(); return; } + if (itemId === 'model') { clearTriggerText(); setShowModelSelector(true); @@ -607,10 +610,11 @@ export const App: React.FC = () => { return; } - // Handle server-provided slash commands by sending them as messages - // CLI will detect slash commands in session/prompt and execute them + // Handle server-provided slash commands by sending them as messages. + // Skip when fillOnly (Tab) — let the generic insertion path fill the + // command text so the user can keep typing arguments. const serverCmd = availableCommands.find((c) => c.name === itemId); - if (serverCmd) { + if (serverCmd && !fillOnly) { // Clear the trigger text since we're sending the command clearTriggerText(); // Send the slash command as a user message @@ -1031,6 +1035,7 @@ export const App: React.FC = () => { completionIsOpen={completion.isOpen} completionItems={completion.items} onCompletionSelect={handleCompletionSelect} + onCompletionFill={(item) => handleCompletionSelect(item, true)} onCompletionClose={completion.closeCompletion} showModelSelector={showModelSelector} availableModels={availableModels} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx new file mode 100644 index 0000000000..8bf5ea26f0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import type React from 'react'; +import { act, createRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { ApprovalMode } from '../../../types/acpTypes.js'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; +import { InputForm } from './InputForm.js'; + +vi.mock('@qwen-code/webui', async () => { + const actual = await vi.importActual( + '../../../../../webui/src/components/layout/InputForm.tsx', + ); + + return { + InputForm: actual.InputForm, + getEditModeIcon: actual.getEditModeIcon, + }; +}); + +const completionItem: CompletionItem = { + id: 'create-issue', + label: '/create-issue', + type: 'command', + value: 'create-issue', +}; + +function renderInputForm(props?: { + onCompletionSelect?: (item: CompletionItem) => void; + onCompletionFill?: (item: CompletionItem) => void; +}) { + const container = document.createElement('div'); + document.body.appendChild(container); + + const root = createRoot(container); + const inputFieldRef = + createRef() as unknown as React.RefObject; + const onCompletionSelect = props?.onCompletionSelect ?? vi.fn(); + const onCompletionFill = props?.onCompletionFill ?? vi.fn(); + + act(() => { + root.render( + , + ); + }); + + return { + container, + root, + onCompletionSelect, + onCompletionFill, + }; +} + +describe('InputForm completion keyboard handling', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it('uses onCompletionFill for Tab without triggering onCompletionSelect', () => { + const rendered = renderInputForm(); + root = rendered.root; + container = rendered.container; + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(rendered.onCompletionFill).toHaveBeenCalledWith(completionItem); + expect(rendered.onCompletionSelect).not.toHaveBeenCalled(); + }); + + it('keeps Enter mapped to onCompletionSelect', () => { + const rendered = renderInputForm(); + root = rendered.root; + container = rendered.container; + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(rendered.onCompletionSelect).toHaveBeenCalledWith(completionItem); + expect(rendered.onCompletionFill).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index cb747aff3b..809f80dbcc 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -13,6 +13,7 @@ import type { InputFormProps as BaseInputFormProps, EditModeInfo, } from '@qwen-code/webui'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; import type { ModelInfo } from '@agentclientprotocol/sdk'; @@ -22,9 +23,11 @@ import { ModelSelector } from './ModelSelector.js'; * Extended props that accept ApprovalModeValue and ModelSelector */ export interface InputFormProps - extends Omit { + extends Omit { /** Edit mode value (local type) */ editMode: ApprovalModeValue; + /** Completion fill callback (Tab or equivalent) */ + onCompletionFill?: (item: CompletionItem) => void; /** Whether to show model selector */ showModelSelector?: boolean; /** Available models for selection */ diff --git a/packages/webui/src/components/layout/CompletionMenu.tsx b/packages/webui/src/components/layout/CompletionMenu.tsx index 06727f7eef..eeefd6da7f 100644 --- a/packages/webui/src/components/layout/CompletionMenu.tsx +++ b/packages/webui/src/components/layout/CompletionMenu.tsx @@ -17,8 +17,10 @@ import type { CompletionItem } from '../../types/completion.js'; export interface CompletionMenuProps { /** List of completion items to display */ items: CompletionItem[]; - /** Callback when an item is selected */ + /** Callback when an item is selected (Enter / click) */ onSelect: (item: CompletionItem) => void; + /** Optional callback for Tab selection (fill without executing). Falls back to onSelect. */ + onFill?: (item: CompletionItem) => void; /** Callback when menu should close */ onClose: () => void; /** Optional section title */ @@ -75,6 +77,7 @@ const groupItems = ( export const CompletionMenu: FC = ({ items, onSelect, + onFill, onClose, title, selectedIndex = 0, @@ -123,12 +126,17 @@ export const CompletionMenu: FC = ({ setSelected((prev) => Math.max(prev - 1, 0)); break; case 'Enter': - case 'Tab': event.preventDefault(); if (items[selected]) { onSelect(items[selected]); } break; + case 'Tab': + event.preventDefault(); + if (items[selected]) { + (onFill ?? onSelect)(items[selected]); + } + break; case 'Escape': event.preventDefault(); onClose(); @@ -144,7 +152,7 @@ export const CompletionMenu: FC = ({ document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleKeyDown); }; - }, [items, selected, onSelect, onClose]); + }, [items, selected, onSelect, onFill, onClose]); useEffect(() => { // Only scroll into view for keyboard navigation, not mouse hover diff --git a/packages/webui/src/components/layout/InputForm.tsx b/packages/webui/src/components/layout/InputForm.tsx index e77f57e248..7edfac03b5 100644 --- a/packages/webui/src/components/layout/InputForm.tsx +++ b/packages/webui/src/components/layout/InputForm.tsx @@ -111,8 +111,10 @@ export interface InputFormProps { completionIsOpen: boolean; /** Completion items */ completionItems?: CompletionItem[]; - /** Completion select callback */ + /** Completion select callback (Enter / click) */ onCompletionSelect?: (item: CompletionItem) => void; + /** Completion fill callback (Tab — fill without executing). Falls back to onCompletionSelect. */ + onCompletionFill?: (item: CompletionItem) => void; /** Completion close callback */ onCompletionClose?: () => void; /** Placeholder text */ @@ -170,6 +172,7 @@ export const InputForm: FC = ({ completionIsOpen, completionItems, onCompletionSelect, + onCompletionFill, onCompletionClose, placeholder = 'Ask Qwen Code …', }) => { @@ -242,6 +245,7 @@ export const InputForm: FC = ({