qwen-code/packages/webui/src/components/layout/InputForm.tsx
tanzhenxin c2ba096351
fix(webui): remove @qwen-code/qwen-code-core dependency (#2902)
Remove core dependency from webui by inlining followup controller logic.
This fixes nightly release CI failure where npm version couldn't resolve
the core peer dependency for prerelease versions.

- Move FollowupState type and INITIAL_FOLLOWUP_STATE to src/types/followup.ts
- Inline createFollowupController into src/hooks/useFollowupSuggestions.ts
- Remove separate @qwen-code/webui/followup subpath and vite config
- Export useFollowupSuggestions from main entry

Fixes #2900

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 13:11:03 +08:00

451 lines
14 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';
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 <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 | null>;
/** 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
* <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,
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 (
<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={actualPlaceholder}
// Indicate when a prompt suggestion is active
data-has-suggestion={hasFollowup ? 'true' : 'false'}
// 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);
// Dismiss follow-up suggestion when user starts typing
if (hasFollowup && !inputText && text) {
onDismissFollowup?.();
}
}}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={onPaste}
suppressContentEditableWarning
/>
</div>
{extraContent ? (
<div className="relative z-[1]">{extraContent}</div>
) : null}
<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 || !hasDraftContent}
aria-label="Send message"
>
<ArrowUpIcon />
</button>
)}
</div>
</form>
</div>
</div>
);
};