mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 06:30:53 +00:00
* feat(vscode-ide-companion): add Tab key fill-only behavior for completions - Separate Tab and Enter key handling in CompletionMenu - Tab now inserts completion text without executing (useful for slash commands) - Enter/click continues to select and execute immediately - Allow users to append arguments after Tab-filling slash commands * feat(vscode-ide-companion): add Tab key fill-only behavior for completions - Separate Tab and Enter key handling in CompletionMenu - Tab now inserts completion text without executing (useful for slash commands) - Enter/click continues to select and execute immediately - Allow users to append arguments after Tab-filling slash commands Co-authored-by: Mingholy <14246397+Mingholy@users.noreply.github.com> * feat: add command selection behavior logic and tests Co-developed-by: Aone Copilot <noreply@alibaba-inc.com> * feat(vscode-ide-companion): add Tab key completion fill behavior with tests - Add onCompletionFill prop to InputForm for Tab key handling - Distinguish Tab (fill) and Enter (select) completion behaviors - Add keyboard handling tests for completion items - Remove 'skills' command from non-interactive CLI allowed list Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor: add itemId variable for command handling in App component Co-developed-by: Aone Copilot <noreply@alibaba-inc.com> * refactor: remove unused command selection behavior utils and tests --------- Co-authored-by: Mingholy <14246397+Mingholy@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*
|
|
* InputForm component - Main chat input with toolbar
|
|
* Platform-agnostic version with configurable edit modes
|
|
*/
|
|
|
|
import type { FC } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import {
|
|
EditPencilIcon,
|
|
AutoEditIcon,
|
|
PlanModeIcon,
|
|
} from '../icons/EditIcons.js';
|
|
import { CodeBracketsIcon, HideContextIcon } from '../icons/EditIcons.js';
|
|
import { SlashCommandIcon, LinkIcon } from '../icons/EditIcons.js';
|
|
import { ArrowUpIcon } from '../icons/NavigationIcons.js';
|
|
import { StopIcon } from '../icons/StopIcon.js';
|
|
import { CompletionMenu } from './CompletionMenu.js';
|
|
import { ContextIndicator } from './ContextIndicator.js';
|
|
import type { CompletionItem } from '../../types/completion.js';
|
|
import type { ContextUsage } from './ContextIndicator.js';
|
|
|
|
/**
|
|
* Edit mode display information
|
|
*/
|
|
export interface EditModeInfo {
|
|
/** Display label */
|
|
label: string;
|
|
/** Tooltip text */
|
|
title: string;
|
|
/** Icon to display */
|
|
icon: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Built-in icon types for edit modes
|
|
*/
|
|
export type EditModeIconType = 'edit' | 'auto' | 'plan' | 'yolo';
|
|
|
|
/**
|
|
* Get icon component for edit mode type
|
|
*/
|
|
export const getEditModeIcon = (iconType: EditModeIconType): ReactNode => {
|
|
switch (iconType) {
|
|
case 'edit':
|
|
return <EditPencilIcon />;
|
|
case 'auto':
|
|
case 'yolo':
|
|
return <AutoEditIcon />;
|
|
case 'plan':
|
|
return <PlanModeIcon />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Props for InputForm component
|
|
*/
|
|
export interface InputFormProps {
|
|
/** Current input text */
|
|
inputText: string;
|
|
/** Ref for the input field */
|
|
inputFieldRef: React.RefObject<HTMLDivElement>;
|
|
/** Whether AI is currently generating */
|
|
isStreaming: boolean;
|
|
/** Whether waiting for response */
|
|
isWaitingForResponse: boolean;
|
|
/** Whether IME composition is in progress */
|
|
isComposing: boolean;
|
|
/** Edit mode display information */
|
|
editModeInfo: EditModeInfo;
|
|
/** Whether thinking mode is enabled */
|
|
thinkingEnabled: boolean;
|
|
/** Active file name (from editor) */
|
|
activeFileName: string | null;
|
|
/** Active selection range */
|
|
activeSelection: { startLine: number; endLine: number } | null;
|
|
/** Whether to skip auto-loading active context */
|
|
skipAutoActiveContext: boolean;
|
|
/** Context usage information */
|
|
contextUsage: ContextUsage | null;
|
|
/** Input change callback */
|
|
onInputChange: (text: string) => void;
|
|
/** Composition start callback */
|
|
onCompositionStart: () => void;
|
|
/** Composition end callback */
|
|
onCompositionEnd: () => void;
|
|
/** Key down callback */
|
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
/** Submit callback */
|
|
onSubmit: (e: React.FormEvent) => void;
|
|
/** Cancel callback */
|
|
onCancel: () => void;
|
|
/** Toggle edit mode callback */
|
|
onToggleEditMode: () => void;
|
|
/** Toggle thinking callback */
|
|
onToggleThinking: () => void;
|
|
/** Focus active editor callback */
|
|
onFocusActiveEditor?: () => void;
|
|
/** Toggle skip auto context callback */
|
|
onToggleSkipAutoActiveContext: () => void;
|
|
/** Show command menu callback */
|
|
onShowCommandMenu: () => void;
|
|
/** Attach context callback */
|
|
onAttachContext: () => void;
|
|
/** Whether completion menu is open */
|
|
completionIsOpen: boolean;
|
|
/** Completion items */
|
|
completionItems?: CompletionItem[];
|
|
/** 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 */
|
|
placeholder?: string;
|
|
}
|
|
|
|
/**
|
|
* InputForm component
|
|
*
|
|
* Features:
|
|
* - ContentEditable input with placeholder
|
|
* - Edit mode toggle with customizable icons
|
|
* - Active file/selection indicator
|
|
* - Context usage display
|
|
* - Command and attach buttons
|
|
* - Send/Stop button based on state
|
|
* - Completion menu integration
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <InputForm
|
|
* inputText={text}
|
|
* inputFieldRef={inputRef}
|
|
* isStreaming={false}
|
|
* isWaitingForResponse={false}
|
|
* isComposing={false}
|
|
* editModeInfo={{ label: 'Auto', title: 'Auto mode', icon: <AutoEditIcon /> }}
|
|
* // ... other props
|
|
* />
|
|
* ```
|
|
*/
|
|
export const InputForm: FC<InputFormProps> = ({
|
|
inputText,
|
|
inputFieldRef,
|
|
isStreaming,
|
|
isWaitingForResponse,
|
|
isComposing,
|
|
editModeInfo,
|
|
// thinkingEnabled, // Temporarily disabled
|
|
activeFileName,
|
|
activeSelection,
|
|
skipAutoActiveContext,
|
|
contextUsage,
|
|
onInputChange,
|
|
onCompositionStart,
|
|
onCompositionEnd,
|
|
onKeyDown,
|
|
onSubmit,
|
|
onCancel,
|
|
onToggleEditMode,
|
|
// onToggleThinking, // Temporarily disabled
|
|
onToggleSkipAutoActiveContext,
|
|
onShowCommandMenu,
|
|
onAttachContext,
|
|
completionIsOpen,
|
|
completionItems,
|
|
onCompletionSelect,
|
|
onCompletionFill,
|
|
onCompletionClose,
|
|
placeholder = 'Ask Qwen Code …',
|
|
}) => {
|
|
const composerDisabled = isStreaming || isWaitingForResponse;
|
|
const completionItemsResolved = completionItems ?? [];
|
|
const completionActive =
|
|
completionIsOpen &&
|
|
completionItemsResolved.length > 0 &&
|
|
!!onCompletionSelect &&
|
|
!!onCompletionClose;
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
// Let the completion menu handle Escape when it's active.
|
|
if (completionActive && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onCompletionClose?.();
|
|
return;
|
|
}
|
|
// ESC should cancel the current interaction (stop generation)
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onCancel();
|
|
return;
|
|
}
|
|
// If composing (Chinese IME input), don't process Enter key
|
|
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
|
|
// If CompletionMenu is open, let it handle Enter key
|
|
if (completionActive) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
onSubmit(e);
|
|
}
|
|
onKeyDown(e);
|
|
};
|
|
|
|
// Selection label like "6 lines selected"; no line numbers
|
|
const selectedLinesCount = activeSelection
|
|
? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1)
|
|
: 0;
|
|
const selectedLinesText =
|
|
selectedLinesCount > 0
|
|
? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected`
|
|
: '';
|
|
|
|
// Pre-compute active file title for accessibility
|
|
const activeFileTitle = activeFileName
|
|
? skipAutoActiveContext
|
|
? selectedLinesText
|
|
? `Active selection will NOT be auto-loaded into context: ${selectedLinesText}`
|
|
: `Active file will NOT be auto-loaded into context: ${activeFileName}`
|
|
: selectedLinesText
|
|
? `Showing your current selection: ${selectedLinesText}`
|
|
: `Showing your current file: ${activeFileName}`
|
|
: '';
|
|
|
|
return (
|
|
<div className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0 bg-gradient-to-b from-transparent to-[var(--app-primary-background)]">
|
|
<div className="block">
|
|
<form className="composer-form" onSubmit={onSubmit}>
|
|
{/* Inner background layer */}
|
|
<div className="composer-overlay" />
|
|
|
|
{/* Banner area */}
|
|
<div className="input-banner" />
|
|
|
|
<div className="relative flex z-[1]">
|
|
{completionActive && onCompletionSelect && onCompletionClose && (
|
|
<CompletionMenu
|
|
items={completionItemsResolved}
|
|
onSelect={onCompletionSelect}
|
|
onFill={onCompletionFill}
|
|
onClose={onCompletionClose}
|
|
title={undefined}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
ref={inputFieldRef}
|
|
contentEditable="plaintext-only"
|
|
className="composer-input"
|
|
role="textbox"
|
|
aria-label="Message input"
|
|
aria-multiline="true"
|
|
data-placeholder={placeholder}
|
|
// Use a data flag so CSS can show placeholder even if the browser
|
|
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
|
|
data-empty={
|
|
inputText.replace(/\u200B/g, '').trim().length === 0
|
|
? 'true'
|
|
: 'false'
|
|
}
|
|
onInput={(e) => {
|
|
const target = e.target as HTMLDivElement;
|
|
// Filter out zero-width space that we use to maintain height
|
|
const text = target.textContent?.replace(/\u200B/g, '') || '';
|
|
onInputChange(text);
|
|
}}
|
|
onCompositionStart={onCompositionStart}
|
|
onCompositionEnd={onCompositionEnd}
|
|
onKeyDown={handleKeyDown}
|
|
suppressContentEditableWarning
|
|
/>
|
|
</div>
|
|
|
|
<div className="composer-actions">
|
|
{/* Edit mode button */}
|
|
<button
|
|
type="button"
|
|
className="btn-text-compact btn-text-compact--primary"
|
|
title={editModeInfo.title}
|
|
aria-label={editModeInfo.label}
|
|
onClick={onToggleEditMode}
|
|
>
|
|
{editModeInfo.icon}
|
|
{/* Let the label truncate with ellipsis; hide on very small screens */}
|
|
<span className="hidden sm:inline">{editModeInfo.label}</span>
|
|
</button>
|
|
|
|
{/* Active file indicator */}
|
|
{activeFileName && (
|
|
<button
|
|
type="button"
|
|
className="btn-text-compact btn-text-compact--primary"
|
|
title={activeFileTitle}
|
|
aria-label={activeFileTitle}
|
|
onClick={onToggleSkipAutoActiveContext}
|
|
>
|
|
{skipAutoActiveContext ? (
|
|
<HideContextIcon />
|
|
) : (
|
|
<CodeBracketsIcon />
|
|
)}
|
|
{/* Truncate file path/selection; hide label on very small screens */}
|
|
<span className="hidden sm:inline">
|
|
{selectedLinesText || activeFileName}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1 min-w-0" />
|
|
|
|
{/* Context usage indicator */}
|
|
<ContextIndicator contextUsage={contextUsage} />
|
|
|
|
{/* Command button */}
|
|
<button
|
|
type="button"
|
|
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
|
|
title="Show command menu (/)"
|
|
aria-label="Show command menu"
|
|
onClick={onShowCommandMenu}
|
|
>
|
|
<SlashCommandIcon />
|
|
</button>
|
|
|
|
{/* Attach button */}
|
|
<button
|
|
type="button"
|
|
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
|
|
title="Attach context (Cmd/Ctrl + /)"
|
|
aria-label="Attach context"
|
|
onClick={onAttachContext}
|
|
>
|
|
<LinkIcon />
|
|
</button>
|
|
|
|
{/* Send/Stop button */}
|
|
{isStreaming || isWaitingForResponse ? (
|
|
<button
|
|
type="button"
|
|
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
|
onClick={onCancel}
|
|
title="Stop generation"
|
|
aria-label="Stop generation"
|
|
>
|
|
<StopIcon />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
|
disabled={composerDisabled || !inputText.trim()}
|
|
aria-label="Send message"
|
|
>
|
|
<ArrowUpIcon />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|