/** * @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'; import type { FollowupState } from '../../types/followup.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 ; case 'auto': case 'yolo': return ; case 'plan': return ; default: return null; } }; /** * Props for InputForm component */ export interface InputFormProps { /** Current input text */ inputText: string; /** Ref for the input field */ inputFieldRef: React.RefObject; /** 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. When explicitText is provided, submit that value instead of reading from input state. */ onSubmit( e: React.FormEvent | React.KeyboardEvent, explicitText?: string, ): 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; /** Optional paste handler for the contentEditable input */ onPaste?: (e: React.ClipboardEvent) => void; /** Optional content rendered between the input and actions */ extraContent?: ReactNode; /** Placeholder text */ placeholder?: string; /** Whether the current draft is eligible to submit */ canSubmit?: boolean; /** Prompt suggestion state */ followupState?: FollowupState; /** Callback to accept prompt suggestion */ onAcceptFollowup?: (method?: 'tab' | 'enter' | 'right') => void; /** Callback to dismiss prompt suggestion */ onDismissFollowup?: () => void; } /** * 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 * }} * // ... other props * /> * ``` */ export const InputForm: FC = ({ 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, onPaste, extraContent, placeholder = 'Ask Qwen Code …', canSubmit, followupState, onAcceptFollowup, onDismissFollowup, }) => { const composerDisabled = isStreaming || isWaitingForResponse; const hasDraftContent = canSubmit ?? inputText.replace(/\u200B/g, '').trim().length > 0; const completionItemsResolved = completionItems ?? []; const completionActive = completionIsOpen && completionItemsResolved.length > 0 && !!onCompletionSelect && !!onCompletionClose; // Prompt suggestion handling const followupSuggestion = followupState?.isVisible && followupState.suggestion ? followupState.suggestion : null; const hasFollowup = !!followupSuggestion; // Compute actual placeholder const actualPlaceholder = hasFollowup && !inputText ? followupSuggestion! : placeholder; 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; } // Tab to accept prompt suggestion (only when callback is wired) if ( e.key === 'Tab' && hasFollowup && onAcceptFollowup && !inputText && !completionActive ) { e.preventDefault(); e.stopPropagation(); onAcceptFollowup('tab'); return; } // Right arrow to accept prompt suggestion (only when callback is wired) if ( e.key === 'ArrowRight' && hasFollowup && onAcceptFollowup && !inputText && !completionActive ) { e.preventDefault(); onAcceptFollowup?.('right'); 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; } // Accept and submit prompt suggestion on Enter when input is empty if (hasFollowup && !inputText && followupSuggestion) { e.preventDefault(); onAcceptFollowup?.('enter'); // Pass suggestion text explicitly — onInputChange is async (React setState) // so onSubmit cannot rely on reading inputText from the closure. onSubmit(e, followupSuggestion); 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 (
{/* Inner background layer */}
{/* Banner area */}
{completionActive && onCompletionSelect && onCompletionClose && ( )}
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); // Dismiss follow-up suggestion when user starts typing if (hasFollowup && !inputText && text) { onDismissFollowup?.(); } }} onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} onKeyDown={handleKeyDown} onPaste={onPaste} suppressContentEditableWarning />
{extraContent ? (
{extraContent}
) : null}
{/* Edit mode button */} {/* Active file indicator */} {activeFileName && ( )} {/* Spacer */}
{/* Context usage indicator */} {/* Command button */} {/* Attach button */} {/* Send/Stop button */} {isStreaming || isWaitingForResponse ? ( ) : ( )}
); };