Merge remote-tracking branch 'origin/main' into feat/plan-mode

# Conflicts:
#	packages/cli/src/i18n/locales/de.js
#	packages/cli/src/i18n/locales/en.js
#	packages/cli/src/i18n/locales/ja.js
#	packages/cli/src/i18n/locales/pt.js
#	packages/cli/src/i18n/locales/ru.js
#	packages/cli/src/i18n/locales/zh.js
This commit is contained in:
wenshao 2026-04-07 21:04:25 +08:00
commit 6bb5e0a276
45 changed files with 1185 additions and 2362 deletions

View file

@ -22,18 +22,7 @@ import { CompletionMenu } from './CompletionMenu.js';
import { ContextIndicator } from './ContextIndicator.js';
import type { CompletionItem } from '../../types/completion.js';
import type { ContextUsage } from './ContextIndicator.js';
/**
* Minimal follow-up state shape used by InputForm.
* Defined locally to avoid pulling @qwen-code/qwen-code-core into the
* root entry's type declarations. The full FollowupState lives in
* '@qwen-code/webui/followup'.
*/
interface InputFormFollowupState {
/** Current suggestion text */
suggestion: string | null;
/** Whether to show suggestion */
isVisible: boolean;
}
import type { FollowupState } from '../../types/followup.js';
/**
* Edit mode display information
@ -141,7 +130,7 @@ export interface InputFormProps {
/** Whether the current draft is eligible to submit */
canSubmit?: boolean;
/** Prompt suggestion state */
followupState?: InputFormFollowupState;
followupState?: FollowupState;
/** Callback to accept prompt suggestion */
onAcceptFollowup?: (method?: 'tab' | 'enter' | 'right') => void;
/** Callback to dismiss prompt suggestion */

View file

@ -1,19 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Prompt Suggestion Subpath Entry
*
* Separated from the root entry to avoid forcing all @qwen-code/webui
* consumers to install @qwen-code/qwen-code-core as a dependency.
*
* Usage: import { useFollowupSuggestions } from '@qwen-code/webui/followup';
*/
export { useFollowupSuggestions } from './hooks/useFollowupSuggestions';
export type {
FollowupState,
UseFollowupSuggestionsOptions,
UseFollowupSuggestionsReturn,
} from './hooks/useFollowupSuggestions';

View file

@ -2,34 +2,27 @@
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Prompt Suggestion Hook
*
* Thin React wrapper around the framework-agnostic controller from core.
*
* Note: For browser environments, the parent component should handle
* suggestion generation and pass the results to this hook.
*/
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
INITIAL_FOLLOWUP_STATE,
createFollowupController,
} from '@qwen-code/qwen-code-core';
import type { FollowupState } from '@qwen-code/qwen-code-core';
import type { FollowupState } from '../types/followup.js';
import { INITIAL_FOLLOWUP_STATE } from '../types/followup.js';
// Re-export types from core for convenience
export type { FollowupState } from '@qwen-code/qwen-code-core';
export type { FollowupState } from '../types/followup.js';
/**
* Options for the hook
*/
export interface UseFollowupSuggestionsOptions {
/** Whether the feature is enabled */
// ---------------------------------------------------------------------------
// Controller (framework-agnostic)
// ---------------------------------------------------------------------------
/** Delay before showing suggestion after response completes */
const SUGGESTION_DELAY_MS = 300;
/** Debounce lock duration to prevent rapid-fire accepts */
const ACCEPT_DEBOUNCE_MS = 100;
interface FollowupControllerOptions {
enabled?: boolean;
/** Callback when suggestion is accepted */
onAccept?: (suggestion: string) => void;
/** Callback when a suggestion outcome is determined */
onStateChange: (state: FollowupState) => void;
getOnAccept?: () => ((text: string) => void) | undefined;
onOutcome?: (params: {
outcome: 'accepted' | 'ignored';
accept_method?: 'tab' | 'enter' | 'right';
@ -38,31 +31,172 @@ export interface UseFollowupSuggestionsOptions {
}) => void;
}
/**
* Result returned by the hook
*/
export interface UseFollowupSuggestionsReturn {
/** Current state */
state: FollowupState;
/** Get current placeholder text */
getPlaceholder: (defaultPlaceholder: string) => string;
/** Set suggestion text (called by parent component) */
interface FollowupControllerActions {
setSuggestion: (text: string | null) => void;
accept: (method?: 'tab' | 'enter' | 'right') => void;
dismiss: () => void;
clear: () => void;
cleanup: () => void;
}
function createFollowupController(
options: FollowupControllerOptions,
): FollowupControllerActions {
const { enabled = true, onStateChange, getOnAccept, onOutcome } = options;
let currentState: FollowupState = INITIAL_FOLLOWUP_STATE;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let accepting = false;
let acceptTimeoutId: ReturnType<typeof setTimeout> | null = null;
function applyState(next: FollowupState): void {
currentState = next;
onStateChange(next);
}
function clearTimers(): void {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
acceptTimeoutId = null;
}
}
const setSuggestion = (text: string | null): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (!text) {
applyState(INITIAL_FOLLOWUP_STATE);
return;
}
if (!enabled) {
return;
}
timeoutId = setTimeout(() => {
applyState({ suggestion: text, isVisible: true, shownAt: Date.now() });
}, SUGGESTION_DELAY_MS);
};
const accept = (method?: 'tab' | 'enter' | 'right'): void => {
if (accepting) {
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
accepting = true;
const text = currentState.suggestion;
const { shownAt } = currentState;
if (!text) {
accepting = false;
return;
}
try {
onOutcome?.({
outcome: 'accepted',
accept_method: method,
time_ms: shownAt > 0 ? Date.now() - shownAt : 0,
suggestion_length: text.length,
});
} catch (e: unknown) {
console.error('[followup] onOutcome callback threw:', e);
}
applyState(INITIAL_FOLLOWUP_STATE);
queueMicrotask(() => {
try {
getOnAccept?.()?.(text);
} catch (error: unknown) {
console.error('[followup] onAccept callback threw:', error);
} finally {
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
}
acceptTimeoutId = setTimeout(() => {
accepting = false;
}, ACCEPT_DEBOUNCE_MS);
}
});
};
const dismiss = (): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (!currentState.isVisible && !currentState.suggestion) {
return;
}
if (currentState.isVisible && currentState.suggestion) {
try {
onOutcome?.({
outcome: 'ignored',
time_ms:
currentState.shownAt > 0 ? Date.now() - currentState.shownAt : 0,
suggestion_length: currentState.suggestion.length,
});
} catch (e: unknown) {
console.error('[followup] onOutcome callback threw:', e);
}
}
applyState(INITIAL_FOLLOWUP_STATE);
};
const clear = (): void => {
clearTimers();
accepting = false;
applyState(INITIAL_FOLLOWUP_STATE);
};
const cleanup = (): void => {
clearTimers();
accepting = false;
};
return { setSuggestion, accept, dismiss, clear, cleanup };
}
// ---------------------------------------------------------------------------
// React hook
// ---------------------------------------------------------------------------
export interface UseFollowupSuggestionsOptions {
enabled?: boolean;
onAccept?: (suggestion: string) => void;
onOutcome?: (params: {
outcome: 'accepted' | 'ignored';
accept_method?: 'tab' | 'enter' | 'right';
time_ms: number;
suggestion_length: number;
}) => void;
}
export interface UseFollowupSuggestionsReturn {
state: FollowupState;
getPlaceholder: (defaultPlaceholder: string) => string;
setSuggestion: (text: string | null) => void;
/** Accept the current suggestion */
accept: (method?: 'tab' | 'enter' | 'right') => void;
/** Dismiss the current suggestion */
dismiss: () => void;
/** Clear all state */
clear: () => void;
}
/**
* Hook for managing prompt suggestions in the Web UI.
*
* Delegates all timer/debounce/state logic to the shared
* `createFollowupController` from core. Adds a `getPlaceholder`
* helper specific to the WebUI input form.
*/
export function useFollowupSuggestions(
options: UseFollowupSuggestionsOptions = {},
): UseFollowupSuggestionsReturn {
@ -70,13 +204,11 @@ export function useFollowupSuggestions(
const [state, setState] = useState<FollowupState>(INITIAL_FOLLOWUP_STATE);
// Keep mutable refs so the controller always sees the latest callbacks
const onAcceptRef = useRef(onAccept);
onAcceptRef.current = onAccept;
const onOutcomeRef = useRef(onOutcome);
onOutcomeRef.current = onOutcome;
// Create the controller once — it is stable across renders
const controller = useMemo(
() =>
createFollowupController({
@ -88,7 +220,6 @@ export function useFollowupSuggestions(
[enabled],
);
// Clear state when disabled; clean up timers on unmount
useEffect(() => {
if (!enabled) {
controller.clear();
@ -96,7 +227,6 @@ export function useFollowupSuggestions(
return () => controller.cleanup();
}, [controller, enabled]);
// WebUI-specific helper: resolves placeholder text
const getPlaceholder = useCallback(
(defaultPlaceholder: string) => {
if (state.isVisible && state.suggestion) {

View file

@ -231,8 +231,12 @@ export { StopIcon } from './components/icons/StopIcon';
// Hooks
export { useTheme } from './hooks/useTheme';
export { useLocalStorage } from './hooks/useLocalStorage';
// NOTE: useFollowupSuggestions is exported from '@qwen-code/webui/followup'
// subpath to avoid forcing all consumers to install @qwen-code/qwen-code-core.
export { useFollowupSuggestions } from './hooks/useFollowupSuggestions';
export type {
FollowupState,
UseFollowupSuggestionsOptions,
UseFollowupSuggestionsReturn,
} from './hooks/useFollowupSuggestions';
// Types
export type { Theme } from './types/theme';

View file

@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface FollowupState {
/** Current suggestion text */
suggestion: string | null;
/** Whether to show suggestion */
isVisible: boolean;
/** Timestamp when suggestion was shown (for telemetry) */
shownAt: number;
}
export const INITIAL_FOLLOWUP_STATE: Readonly<FollowupState> = Object.freeze({
suggestion: null,
isVisible: false,
shownAt: 0,
});