Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions packages/vscode-ide-companion/src/webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export const App: React.FC = () => {
description: cmd.description,
type: 'command' as const,
group: 'Slash Commands',
value: cmd.name,
}),
);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -593,24 +596,25 @@ 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);
completion.closeCompletion();
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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>() as unknown as React.RefObject<HTMLDivElement>;
const onCompletionSelect = props?.onCompletionSelect ?? vi.fn();
const onCompletionFill = props?.onCompletionFill ?? vi.fn();

act(() => {
root.render(
<InputForm
inputText=""
inputFieldRef={inputFieldRef}
isStreaming={false}
isWaitingForResponse={false}
isComposing={false}
editMode={ApprovalMode.DEFAULT}
thinkingEnabled={false}
activeFileName={null}
activeSelection={null}
skipAutoActiveContext={false}
contextUsage={null}
onInputChange={vi.fn()}
onCompositionStart={vi.fn()}
onCompositionEnd={vi.fn()}
onKeyDown={vi.fn()}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onToggleEditMode={vi.fn()}
onToggleThinking={vi.fn()}
onToggleSkipAutoActiveContext={vi.fn()}
onShowCommandMenu={vi.fn()}
onAttachContext={vi.fn()}
completionIsOpen={true}
completionItems={[completionItem]}
onCompletionSelect={onCompletionSelect}
onCompletionFill={onCompletionFill}
onCompletionClose={vi.fn()}
/>,
);
});

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,9 +23,11 @@ import { ModelSelector } from './ModelSelector.js';
* Extended props that accept ApprovalModeValue and ModelSelector
*/
export interface InputFormProps
extends Omit<BaseInputFormProps, 'editModeInfo'> {
extends Omit<BaseInputFormProps, 'editModeInfo' | 'onCompletionFill'> {
/** 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 */
Expand Down
14 changes: 11 additions & 3 deletions packages/webui/src/components/layout/CompletionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -75,6 +77,7 @@ const groupItems = (
export const CompletionMenu: FC<CompletionMenuProps> = ({
items,
onSelect,
onFill,
onClose,
title,
selectedIndex = 0,
Expand Down Expand Up @@ -123,12 +126,17 @@ export const CompletionMenu: FC<CompletionMenuProps> = ({
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();
Expand All @@ -144,7 +152,7 @@ export const CompletionMenu: FC<CompletionMenuProps> = ({
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
Expand Down
6 changes: 5 additions & 1 deletion packages/webui/src/components/layout/InputForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -170,6 +172,7 @@ export const InputForm: FC<InputFormProps> = ({
completionIsOpen,
completionItems,
onCompletionSelect,
onCompletionFill,
onCompletionClose,
placeholder = 'Ask Qwen Code …',
}) => {
Expand Down Expand Up @@ -242,6 +245,7 @@ export const InputForm: FC<InputFormProps> = ({
<CompletionMenu
items={completionItemsResolved}
onSelect={onCompletionSelect}
onFill={onCompletionFill}
onClose={onCompletionClose}
title={undefined}
/>
Expand Down
Loading