mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 07:10:55 +00:00
Merge branch 'main' of https://github.com/qqqys/qwen-code into fix/ime_vscode
This commit is contained in:
commit
d169aeb3ca
189 changed files with 16656 additions and 2408 deletions
|
|
@ -190,6 +190,8 @@ describe('AppContainer State Management', () => {
|
|||
isAuthDialogOpen: false,
|
||||
isAuthenticating: false,
|
||||
handleAuthSelect: vi.fn(),
|
||||
handleCodingPlanSubmit: vi.fn(),
|
||||
handleAlibabaStandardSubmit: vi.fn(),
|
||||
openAuthDialog: vi.fn(),
|
||||
cancelAuthentication: vi.fn(),
|
||||
});
|
||||
|
|
@ -434,6 +436,41 @@ describe('AppContainer State Management', () => {
|
|||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('submits /btw immediately instead of queueing while responding', () => {
|
||||
const mockSubmitQuery = vi.fn();
|
||||
const mockQueueMessage = vi.fn();
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: mockSubmitQuery,
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
messageQueue: [],
|
||||
addMessage: mockQueueMessage,
|
||||
clearQueue: vi.fn(),
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
capturedUIActions.handleFinalSubmit('/btw quick side question');
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith('/btw quick side question');
|
||||
expect(mockQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings Integration', () => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
Storage,
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
type PermissionMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
|
|
@ -71,6 +72,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
|||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { isBtwCommand } from './utils/commandUtils.js';
|
||||
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
|
|
@ -108,6 +110,7 @@ import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
|||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
|
||||
import { useMcpDialog } from './hooks/useMcpDialog.js';
|
||||
import { useHooksDialog } from './hooks/useHooksDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
|
|
@ -307,7 +310,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
|
||||
if (hookSystem) {
|
||||
hookSystem
|
||||
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
|
||||
.fireSessionStartEvent(
|
||||
sessionStartSource,
|
||||
config.getModel() ?? '',
|
||||
String(config.getApprovalMode()) as PermissionMode,
|
||||
)
|
||||
.then(() => {
|
||||
debugLogger.debug('SessionStart event completed successfully');
|
||||
})
|
||||
|
|
@ -450,6 +457,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
handleAlibabaStandardSubmit,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
|
||||
|
|
@ -546,6 +554,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeExtensionsManagerDialog,
|
||||
} = useExtensionsManagerDialog();
|
||||
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
|
||||
const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } =
|
||||
useHooksDialog();
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
|
|
@ -572,6 +582,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openHooksDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
|
|
@ -591,6 +602,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openHooksDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
|
@ -599,6 +611,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleSlashCommand,
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
|
|
@ -747,9 +762,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
streamingState === StreamingState.Responding &&
|
||||
isBtwCommand(submittedValue)
|
||||
) {
|
||||
void submitQuery(submittedValue);
|
||||
return;
|
||||
}
|
||||
addMessage(submittedValue);
|
||||
},
|
||||
[addMessage, agentViewState],
|
||||
[addMessage, agentViewState, streamingState, submitQuery],
|
||||
);
|
||||
|
||||
const handleArenaModelsSelected = useCallback(
|
||||
|
|
@ -947,6 +969,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
|
||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const dialogsVisibleRef = useRef(false);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
|
|
@ -1233,7 +1256,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
return;
|
||||
} else if (keyMatchers[Command.ESCAPE](key)) {
|
||||
// Escape key handling
|
||||
// Dismiss or cancel btw side-question on Escape,
|
||||
// but only when btw is actually visible (not hidden behind a dialog).
|
||||
if (btwItem && !dialogsVisibleRef.current) {
|
||||
cancelBtw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if shell is focused (to allow shell's own escape handling)
|
||||
if (embeddedShellFocused) {
|
||||
return;
|
||||
|
|
@ -1275,6 +1304,20 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Dismiss completed btw side-question on Space or Enter,
|
||||
// but only when btw is visible and the input buffer is empty.
|
||||
if (
|
||||
btwItem &&
|
||||
!btwItem.btw.isPending &&
|
||||
!dialogsVisibleRef.current &&
|
||||
buffer.text.length === 0
|
||||
) {
|
||||
if (key.name === 'return' || key.sequence === ' ') {
|
||||
setBtwItem(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
|
|
@ -1329,6 +1372,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleSlashCommand,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
settings.merged.general?.debugKeystrokeLogging,
|
||||
isAuthenticating,
|
||||
],
|
||||
|
|
@ -1399,9 +1445,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isMcpDialogOpen ||
|
||||
isHooksDialogOpen ||
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen ||
|
||||
isExtensionsManagerDialogOpen;
|
||||
dialogsVisibleRef.current = dialogsVisible;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
|
|
@ -1492,6 +1540,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
staticExtraHeight,
|
||||
dialogsVisible,
|
||||
pendingHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
nightly,
|
||||
branchName,
|
||||
sessionStats,
|
||||
|
|
@ -1517,6 +1568,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Hooks dialog
|
||||
isHooksDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
// Per-task token tracking
|
||||
|
|
@ -1588,6 +1641,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
staticExtraHeight,
|
||||
dialogsVisible,
|
||||
pendingHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
nightly,
|
||||
branchName,
|
||||
sessionStats,
|
||||
|
|
@ -1615,6 +1671,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Hooks dialog
|
||||
isHooksDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
// Per-task token tracking
|
||||
|
|
@ -1634,6 +1692,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
onAuthError,
|
||||
cancelAuthentication,
|
||||
handleCodingPlanSubmit,
|
||||
handleAlibabaStandardSubmit,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
|
|
@ -1666,6 +1725,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Hooks dialog
|
||||
openHooksDialog,
|
||||
// Hooks dialog
|
||||
closeHooksDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
@ -1687,6 +1750,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
onAuthError,
|
||||
cancelAuthentication,
|
||||
handleCodingPlanSubmit,
|
||||
handleAlibabaStandardSubmit,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
|
|
@ -1717,6 +1781,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Hooks dialog
|
||||
openHooksDialog,
|
||||
// Hooks dialog
|
||||
closeHooksDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
|
|||
// AuthDialog only uses handleAuthSelect
|
||||
const baseActions = {
|
||||
handleAuthSelect: vi.fn(),
|
||||
handleCodingPlanSubmit: vi.fn(),
|
||||
handleAlibabaStandardSubmit: vi.fn(),
|
||||
onAuthError: vi.fn(),
|
||||
handleRetryLastPrompt: vi.fn(),
|
||||
} as Partial<UIActions>;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { theme } from '../semantic-colors.js';
|
|||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ApiKeyInput } from '../components/ApiKeyInput.js';
|
||||
import { TextInput } from '../components/shared/TextInput.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
|
|
@ -21,6 +22,10 @@ import {
|
|||
CodingPlanRegion,
|
||||
isCodingPlanConfig,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import {
|
||||
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
|
||||
type AlibabaStandardRegion,
|
||||
} from '../../constants/alibabaStandardApiKey.js';
|
||||
|
||||
const MODEL_PROVIDERS_DOCUMENTATION_URL =
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/';
|
||||
|
|
@ -39,15 +44,39 @@ function parseDefaultAuthType(
|
|||
|
||||
// Main menu option type
|
||||
type MainOption = typeof AuthType.QWEN_OAUTH | 'CODING_PLAN' | 'API_KEY';
|
||||
type ApiKeyOption = 'ALIBABA_STANDARD_API_KEY' | 'CUSTOM_API_KEY';
|
||||
|
||||
// View level for navigation
|
||||
type ViewLevel = 'main' | 'region-select' | 'api-key-input' | 'custom-info';
|
||||
type ViewLevel =
|
||||
| 'main'
|
||||
| 'region-select'
|
||||
| 'api-key-input'
|
||||
| 'api-key-type-select'
|
||||
| 'alibaba-standard-region-select'
|
||||
| 'alibaba-standard-api-key-input'
|
||||
| 'alibaba-standard-model-id-input'
|
||||
| 'custom-info';
|
||||
|
||||
const ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER = 'qwen3.5-plus,glm-5,kimi-k2.5';
|
||||
const ALIBABA_STANDARD_API_DOCUMENTATION_URLS: Record<
|
||||
AlibabaStandardRegion,
|
||||
string
|
||||
> = {
|
||||
'cn-beijing': 'https://bailian.console.aliyun.com/cn-beijing?tab=api#/api',
|
||||
'sg-singapore':
|
||||
'https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=api#/api/?type=model&url=2712195',
|
||||
'us-virginia':
|
||||
'https://modelstudio.console.alibabacloud.com/us-east-1?tab=api#/api/?type=model&url=2712195',
|
||||
'cn-hongkong':
|
||||
'https://modelstudio.console.alibabacloud.com/cn-hongkong?tab=api#/api/?type=model&url=2712195',
|
||||
};
|
||||
|
||||
export function AuthDialog(): React.JSX.Element {
|
||||
const { pendingAuthType, authError } = useUIState();
|
||||
const {
|
||||
handleAuthSelect: onAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
handleAlibabaStandardSubmit,
|
||||
onAuthError,
|
||||
} = useUIActions();
|
||||
const config = useConfig();
|
||||
|
|
@ -58,6 +87,18 @@ export function AuthDialog(): React.JSX.Element {
|
|||
const [region, setRegion] = useState<CodingPlanRegion>(
|
||||
CodingPlanRegion.CHINA,
|
||||
);
|
||||
const [alibabaStandardRegionIndex, setAlibabaStandardRegionIndex] =
|
||||
useState<number>(0);
|
||||
const [apiKeyTypeIndex, setApiKeyTypeIndex] = useState<number>(0);
|
||||
const [alibabaStandardRegion, setAlibabaStandardRegion] =
|
||||
useState<AlibabaStandardRegion>('cn-beijing');
|
||||
const [alibabaStandardApiKey, setAlibabaStandardApiKey] = useState('');
|
||||
const [alibabaStandardApiKeyError, setAlibabaStandardApiKeyError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [alibabaStandardModelId, setAlibabaStandardModelId] = useState('');
|
||||
const [alibabaStandardModelIdError, setAlibabaStandardModelIdError] =
|
||||
useState<string | null>(null);
|
||||
|
||||
// Main authentication entries (flat three-option layout)
|
||||
const mainItems = [
|
||||
|
|
@ -124,21 +165,87 @@ export function AuthDialog(): React.JSX.Element {
|
|||
},
|
||||
];
|
||||
|
||||
const alibabaStandardRegionItems = [
|
||||
{
|
||||
key: 'cn-beijing',
|
||||
title: t('China (Beijing)'),
|
||||
label: t('China (Beijing)'),
|
||||
description: (
|
||||
<Text color={theme.text.secondary}>
|
||||
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-beijing']}
|
||||
</Text>
|
||||
),
|
||||
value: 'cn-beijing' as AlibabaStandardRegion,
|
||||
},
|
||||
{
|
||||
key: 'sg-singapore',
|
||||
title: t('Singapore'),
|
||||
label: t('Singapore'),
|
||||
description: (
|
||||
<Text color={theme.text.secondary}>
|
||||
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['sg-singapore']}
|
||||
</Text>
|
||||
),
|
||||
value: 'sg-singapore' as AlibabaStandardRegion,
|
||||
},
|
||||
{
|
||||
key: 'us-virginia',
|
||||
title: t('US (Virginia)'),
|
||||
label: t('US (Virginia)'),
|
||||
description: (
|
||||
<Text color={theme.text.secondary}>
|
||||
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['us-virginia']}
|
||||
</Text>
|
||||
),
|
||||
value: 'us-virginia' as AlibabaStandardRegion,
|
||||
},
|
||||
{
|
||||
key: 'cn-hongkong',
|
||||
title: t('China (Hong Kong)'),
|
||||
label: t('China (Hong Kong)'),
|
||||
description: (
|
||||
<Text color={theme.text.secondary}>
|
||||
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-hongkong']}
|
||||
</Text>
|
||||
),
|
||||
value: 'cn-hongkong' as AlibabaStandardRegion,
|
||||
},
|
||||
];
|
||||
|
||||
const apiKeyTypeItems = [
|
||||
{
|
||||
key: 'ALIBABA_STANDARD_API_KEY',
|
||||
title: t('Alibaba Cloud ModelStudio Standard API Key'),
|
||||
label: t('Alibaba Cloud ModelStudio Standard API Key'),
|
||||
description: t('Quick setup for Model Studio (China/International)'),
|
||||
value: 'ALIBABA_STANDARD_API_KEY' as ApiKeyOption,
|
||||
},
|
||||
{
|
||||
key: 'CUSTOM_API_KEY',
|
||||
title: t('Custom API Key'),
|
||||
label: t('Custom API Key'),
|
||||
description: t(
|
||||
'For other OpenAI / Anthropic / Gemini-compatible providers',
|
||||
),
|
||||
value: 'CUSTOM_API_KEY' as ApiKeyOption,
|
||||
},
|
||||
];
|
||||
|
||||
// Map an AuthType to the corresponding main menu option.
|
||||
// QWEN_OAUTH maps directly; any other auth type maps to CODING_PLAN only
|
||||
// if the current config actually uses a Coding Plan baseUrl+envKey,
|
||||
// otherwise it maps to API_KEY.
|
||||
// QWEN_OAUTH maps directly; USE_OPENAI maps to:
|
||||
// - CODING_PLAN when current config matches coding plan
|
||||
// - API_KEY for other OpenAI / Anthropic / Gemini-compatible configs
|
||||
const contentGenConfig = config.getContentGeneratorConfig();
|
||||
const isCurrentlyCodingPlan =
|
||||
isCodingPlanConfig(
|
||||
contentGenConfig?.baseUrl,
|
||||
contentGenConfig?.apiKeyEnvKey,
|
||||
) !== false;
|
||||
|
||||
const authTypeToMainOption = (authType: AuthType): MainOption => {
|
||||
if (authType === AuthType.QWEN_OAUTH) return AuthType.QWEN_OAUTH;
|
||||
if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan)
|
||||
if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan) {
|
||||
return 'CODING_PLAN';
|
||||
}
|
||||
return 'API_KEY';
|
||||
};
|
||||
|
||||
|
|
@ -180,8 +287,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
}
|
||||
|
||||
if (value === 'API_KEY') {
|
||||
// Navigate directly to custom API key info
|
||||
setViewLevel('custom-info');
|
||||
setViewLevel('api-key-type-select');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +295,20 @@ export function AuthDialog(): React.JSX.Element {
|
|||
await onAuthSelect(value);
|
||||
};
|
||||
|
||||
const handleApiKeyTypeSelect = async (value: ApiKeyOption) => {
|
||||
setErrorMessage(null);
|
||||
onAuthError(null);
|
||||
|
||||
if (value === 'ALIBABA_STANDARD_API_KEY') {
|
||||
setAlibabaStandardModelIdError(null);
|
||||
setAlibabaStandardApiKeyError(null);
|
||||
setViewLevel('alibaba-standard-region-select');
|
||||
return;
|
||||
}
|
||||
|
||||
setViewLevel('custom-info');
|
||||
};
|
||||
|
||||
const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => {
|
||||
setErrorMessage(null);
|
||||
onAuthError(null);
|
||||
|
|
@ -196,6 +316,17 @@ export function AuthDialog(): React.JSX.Element {
|
|||
setViewLevel('api-key-input');
|
||||
};
|
||||
|
||||
const handleAlibabaStandardRegionSelect = async (
|
||||
selectedRegion: AlibabaStandardRegion,
|
||||
) => {
|
||||
setErrorMessage(null);
|
||||
onAuthError(null);
|
||||
setAlibabaStandardApiKeyError(null);
|
||||
setAlibabaStandardModelIdError(null);
|
||||
setAlibabaStandardRegion(selectedRegion);
|
||||
setViewLevel('alibaba-standard-api-key-input');
|
||||
};
|
||||
|
||||
const handleApiKeyInputSubmit = async (apiKey: string) => {
|
||||
setErrorMessage(null);
|
||||
|
||||
|
|
@ -208,14 +339,59 @@ export function AuthDialog(): React.JSX.Element {
|
|||
await handleCodingPlanSubmit(apiKey, region);
|
||||
};
|
||||
|
||||
const handleAlibabaStandardApiKeySubmit = () => {
|
||||
const trimmedKey = alibabaStandardApiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
setAlibabaStandardApiKeyError(t('API key cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setAlibabaStandardApiKeyError(null);
|
||||
if (!alibabaStandardModelId.trim()) {
|
||||
setAlibabaStandardModelId(ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER);
|
||||
}
|
||||
setViewLevel('alibaba-standard-model-id-input');
|
||||
};
|
||||
|
||||
const handleAlibabaStandardModelSubmit = () => {
|
||||
const trimmedApiKey = alibabaStandardApiKey.trim();
|
||||
const trimmedModelIds = alibabaStandardModelId.trim();
|
||||
if (!trimmedApiKey) {
|
||||
setAlibabaStandardApiKeyError(t('API key cannot be empty.'));
|
||||
setViewLevel('alibaba-standard-api-key-input');
|
||||
return;
|
||||
}
|
||||
if (!trimmedModelIds) {
|
||||
setAlibabaStandardModelIdError(t('Model IDs cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setAlibabaStandardModelIdError(null);
|
||||
void handleAlibabaStandardSubmit(
|
||||
trimmedApiKey,
|
||||
alibabaStandardRegion,
|
||||
trimmedModelIds,
|
||||
);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
setErrorMessage(null);
|
||||
onAuthError(null);
|
||||
|
||||
if (viewLevel === 'region-select' || viewLevel === 'custom-info') {
|
||||
if (viewLevel === 'region-select') {
|
||||
setViewLevel('main');
|
||||
} else if (viewLevel === 'api-key-input') {
|
||||
setViewLevel('region-select');
|
||||
} else if (viewLevel === 'api-key-type-select') {
|
||||
setViewLevel('main');
|
||||
} else if (viewLevel === 'custom-info') {
|
||||
setViewLevel('api-key-type-select');
|
||||
} else if (viewLevel === 'alibaba-standard-region-select') {
|
||||
setViewLevel('api-key-type-select');
|
||||
} else if (viewLevel === 'alibaba-standard-api-key-input') {
|
||||
setViewLevel('alibaba-standard-region-select');
|
||||
} else if (viewLevel === 'alibaba-standard-model-id-input') {
|
||||
setViewLevel('alibaba-standard-api-key-input');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -232,6 +408,15 @@ export function AuthDialog(): React.JSX.Element {
|
|||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
viewLevel === 'api-key-type-select' ||
|
||||
viewLevel === 'alibaba-standard-region-select' ||
|
||||
viewLevel === 'alibaba-standard-api-key-input' ||
|
||||
viewLevel === 'alibaba-standard-model-id-input'
|
||||
) {
|
||||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// For main view, use existing logic
|
||||
if (errorMessage) {
|
||||
|
|
@ -304,6 +489,135 @@ export function AuthDialog(): React.JSX.Element {
|
|||
</Box>
|
||||
);
|
||||
|
||||
const renderApiKeyTypeSelectView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={apiKeyTypeItems}
|
||||
initialIndex={apiKeyTypeIndex}
|
||||
onSelect={handleApiKeyTypeSelect}
|
||||
onHighlight={(value) => {
|
||||
const index = apiKeyTypeItems.findIndex(
|
||||
(item) => item.value === value,
|
||||
);
|
||||
setApiKeyTypeIndex(index);
|
||||
}}
|
||||
itemGap={1}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary}>
|
||||
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderAlibabaStandardRegionSelectView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={alibabaStandardRegionItems}
|
||||
initialIndex={alibabaStandardRegionIndex}
|
||||
onSelect={handleAlibabaStandardRegionSelect}
|
||||
onHighlight={(value) => {
|
||||
const index = alibabaStandardRegionItems.findIndex(
|
||||
(item) => item.value === value,
|
||||
);
|
||||
setAlibabaStandardRegionIndex(index);
|
||||
}}
|
||||
itemGap={1}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary}>
|
||||
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderAlibabaStandardApiKeyInputView = () => (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS[alibabaStandardRegion]}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Documentation')}:</Text>
|
||||
</Box>
|
||||
<Box marginTop={0}>
|
||||
<Link
|
||||
url={ALIBABA_STANDARD_API_DOCUMENTATION_URLS[alibabaStandardRegion]}
|
||||
fallback={false}
|
||||
>
|
||||
<Text color={theme.text.link}>
|
||||
{ALIBABA_STANDARD_API_DOCUMENTATION_URLS[alibabaStandardRegion]}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<TextInput
|
||||
value={alibabaStandardApiKey}
|
||||
onChange={(value) => {
|
||||
setAlibabaStandardApiKey(value);
|
||||
if (alibabaStandardApiKeyError) {
|
||||
setAlibabaStandardApiKeyError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={handleAlibabaStandardApiKeySubmit}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</Box>
|
||||
{alibabaStandardApiKeyError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>{alibabaStandardApiKeyError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to submit, Esc to go back')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderAlibabaStandardModelIdInputView = () => (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'You can enter multiple model IDs, separated by commas. Examples: qwen3.5-plus,glm-5,kimi-k2.5',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<TextInput
|
||||
value={alibabaStandardModelId}
|
||||
onChange={(value) => {
|
||||
setAlibabaStandardModelId(value);
|
||||
if (alibabaStandardModelIdError) {
|
||||
setAlibabaStandardModelIdError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={handleAlibabaStandardModelSubmit}
|
||||
placeholder={ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER}
|
||||
/>
|
||||
</Box>
|
||||
{alibabaStandardModelIdError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>{alibabaStandardModelIdError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to submit, Esc to go back')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Render custom mode info
|
||||
const renderCustomInfoView = () => (
|
||||
<>
|
||||
|
|
@ -336,8 +650,18 @@ export function AuthDialog(): React.JSX.Element {
|
|||
return t('Select Region for Coding Plan');
|
||||
case 'api-key-input':
|
||||
return t('Enter Coding Plan API Key');
|
||||
case 'api-key-type-select':
|
||||
return t('Select API Key Type');
|
||||
case 'custom-info':
|
||||
return t('Custom Configuration');
|
||||
case 'alibaba-standard-region-select':
|
||||
return t(
|
||||
'Select Region for Alibaba Cloud ModelStudio Standard API Key',
|
||||
);
|
||||
case 'alibaba-standard-api-key-input':
|
||||
return t('Enter Alibaba Cloud ModelStudio Standard API Key');
|
||||
case 'alibaba-standard-model-id-input':
|
||||
return t('Enter Model IDs');
|
||||
default:
|
||||
return t('Select Authentication Method');
|
||||
}
|
||||
|
|
@ -356,6 +680,13 @@ export function AuthDialog(): React.JSX.Element {
|
|||
{viewLevel === 'main' && renderMainView()}
|
||||
{viewLevel === 'region-select' && renderRegionSelectView()}
|
||||
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
|
||||
{viewLevel === 'api-key-type-select' && renderApiKeyTypeSelectView()}
|
||||
{viewLevel === 'alibaba-standard-region-select' &&
|
||||
renderAlibabaStandardRegionSelectView()}
|
||||
{viewLevel === 'alibaba-standard-api-key-input' &&
|
||||
renderAlibabaStandardApiKeyInputView()}
|
||||
{viewLevel === 'alibaba-standard-model-id-input' &&
|
||||
renderAlibabaStandardModelIdInputView()}
|
||||
{viewLevel === 'custom-info' && renderCustomInfoView()}
|
||||
|
||||
{(authError || errorMessage) && (
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ import {
|
|||
CODING_PLAN_ENV_KEY,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import { backupSettingsFile } from '../../utils/settingsUtils.js';
|
||||
import {
|
||||
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
|
||||
DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
|
||||
type AlibabaStandardRegion,
|
||||
} from '../../constants/alibabaStandardApiKey.js';
|
||||
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
||||
|
|
@ -421,6 +426,134 @@ export const useAuthCommand = (
|
|||
[settings, config, handleAuthFailure, addItem, onAuthChange],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle Alibaba Cloud standard API key flow.
|
||||
* Persists key to env.DASHSCOPE_API_KEY and creates a modelProviders.openai entry.
|
||||
*/
|
||||
const handleAlibabaStandardSubmit = useCallback(
|
||||
async (
|
||||
apiKey: string,
|
||||
region: AlibabaStandardRegion,
|
||||
modelIdsInput: string,
|
||||
) => {
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
setAuthError(null);
|
||||
|
||||
const trimmedApiKey = apiKey.trim();
|
||||
const modelIds = modelIdsInput
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(
|
||||
(id, index, array) => id.length > 0 && array.indexOf(id) === index,
|
||||
);
|
||||
if (!trimmedApiKey) {
|
||||
throw new Error(t('API key cannot be empty.'));
|
||||
}
|
||||
if (modelIds.length === 0) {
|
||||
throw new Error(t('Model IDs cannot be empty.'));
|
||||
}
|
||||
|
||||
const baseUrl = ALIBABA_STANDARD_API_KEY_ENDPOINTS[region];
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
const settingsFile = settings.forScope(persistScope);
|
||||
backupSettingsFile(settingsFile.path);
|
||||
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`env.${DASHSCOPE_STANDARD_API_KEY_ENV_KEY}`,
|
||||
trimmedApiKey,
|
||||
);
|
||||
process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] = trimmedApiKey;
|
||||
|
||||
const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({
|
||||
id: modelId,
|
||||
name: `[ModelStudio Standard] ${modelId}`,
|
||||
baseUrl,
|
||||
envKey: DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
|
||||
}));
|
||||
|
||||
const existingConfigs =
|
||||
(
|
||||
settings.merged.modelProviders as ModelProvidersConfig | undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
|
||||
const nonAlibabaStandardConfigs = existingConfigs.filter(
|
||||
(existing) =>
|
||||
!(
|
||||
existing.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY &&
|
||||
typeof existing.baseUrl === 'string' &&
|
||||
Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes(
|
||||
existing.baseUrl,
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
const updatedConfigs = [...newConfigs, ...nonAlibabaStandardConfigs];
|
||||
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
settings.setValue(persistScope, 'model.name', modelIds[0]);
|
||||
|
||||
const updatedModelProviders: ModelProvidersConfig = {
|
||||
...(settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(updatedModelProviders);
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
setPendingAuthType(undefined);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(false);
|
||||
onAuthChange?.();
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Alibaba Cloud ModelStudio Standard API Key successfully entered. Settings updated with env.DASHSCOPE_API_KEY and {{modelCount}} model(s).',
|
||||
{ modelCount: String(modelIds.length) },
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'You can use /model to see new ModelStudio Standard models and switch between them.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const authEvent = new AuthEvent(
|
||||
AuthType.USE_OPENAI,
|
||||
'manual',
|
||||
'success',
|
||||
);
|
||||
logAuth(config, authEvent);
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
}
|
||||
},
|
||||
[settings, config, handleAuthFailure, addItem, onAuthChange],
|
||||
);
|
||||
|
||||
/**
|
||||
/**
|
||||
* We previously used a useEffect to trigger authentication automatically when
|
||||
|
|
@ -472,6 +605,7 @@ export const useAuthCommand = (
|
|||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
handleAlibabaStandardSubmit,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
};
|
||||
|
|
|
|||
464
packages/cli/src/ui/commands/btwCommand.test.ts
Normal file
464
packages/cli/src/ui/commands/btwCommand.test.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { btwCommand } from './btwCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) {
|
||||
return Object.entries(params).reduce(
|
||||
(str, [k, v]) => str.replace(`{{${k}}}`, v),
|
||||
key,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('btwCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockGenerateContent: ReturnType<typeof vi.fn>;
|
||||
let mockGetHistory: ReturnType<typeof vi.fn>;
|
||||
const createConfig = (overrides: Record<string, unknown> = {}) => ({
|
||||
getGeminiClient: () => ({
|
||||
getHistory: mockGetHistory,
|
||||
generateContent: mockGenerateContent,
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getSessionId: () => 'test-session-id',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGenerateContent = vi.fn();
|
||||
mockGetHistory = vi.fn().mockReturnValue([]);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(btwCommand.name).toBe('btw');
|
||||
expect(btwCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
expect(btwCommand.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return error when no question is provided', async () => {
|
||||
const result = await btwCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Please provide a question. Usage: /btw <your question>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when only whitespace is provided', async () => {
|
||||
const result = await btwCommand.action!(mockContext, ' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Please provide a question. Usage: /btw <your question>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when config is not loaded', async () => {
|
||||
const noConfigContext = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(noConfigContext, 'test question');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when model is not configured', async () => {
|
||||
const noModelContext = createMockCommandContext({
|
||||
services: {
|
||||
config: createConfig({
|
||||
getModel: () => '',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(noModelContext, 'test question');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No model configured.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive mode', () => {
|
||||
const flushPromises = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
it('should set btwItem and update it on success', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'The answer is 42.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'what is the meaning of life?');
|
||||
|
||||
// Action returns immediately; btwItem is set synchronously
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question: 'what is the meaning of life?',
|
||||
answer: '',
|
||||
isPending: true,
|
||||
},
|
||||
});
|
||||
|
||||
// pendingItem should NOT be used
|
||||
expect(mockContext.ui.setPendingItem).not.toHaveBeenCalled();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// On success, setBtwItem is called with the completed answer
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question: 'what is the meaning of life?',
|
||||
answer: 'The answer is 42.',
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
// addItem should NOT be called (btw stays in fixed area, not in history)
|
||||
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass conversation history to generateContent', async () => {
|
||||
const history = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi!' }] },
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'my question');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith(
|
||||
[
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: expect.stringContaining('my question'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{},
|
||||
expect.any(AbortSignal),
|
||||
'test-model',
|
||||
expect.stringMatching(/^test-session-id########btw-/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add error item on failure and clear btwItem', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('API error'));
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
await flushPromises();
|
||||
|
||||
// btwItem should be cleared on error
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith(null);
|
||||
|
||||
// Error goes to history
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to answer btw question: API error',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
mockGenerateContent.mockRejectedValue('string error');
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to answer btw question: string error',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not block when another pendingItem exists', async () => {
|
||||
const busyContext = createMockCommandContext({
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
ui: {
|
||||
pendingItem: { type: 'info' },
|
||||
},
|
||||
});
|
||||
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
// btw should NOT be blocked by pendingItem anymore
|
||||
const result = await btwCommand.action!(busyContext, 'test question');
|
||||
expect(result).toBeUndefined();
|
||||
expect(busyContext.ui.setBtwItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update btwItem when cancelled via btwAbortControllerRef', async () => {
|
||||
mockGenerateContent.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'late answer' }] } },
|
||||
],
|
||||
}),
|
||||
50,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
// The btw command should have registered its AbortController
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
|
||||
AbortController,
|
||||
);
|
||||
|
||||
// Simulate user pressing ESC: cancel the in-flight btw
|
||||
mockContext.ui.btwAbortControllerRef.current!.abort();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// setBtwItem should only have the initial pending call (no completion)
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear btwAbortControllerRef after successful completion', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
// Ref is set during the call
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
|
||||
AbortController,
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// After completion, ref should be cleaned up
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear btwAbortControllerRef after error', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('API error'));
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
|
||||
AbortController,
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it('should cancel previous btw when starting a new one', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'first question');
|
||||
|
||||
// cancelBtw should have been called to clean up any previous btw
|
||||
expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second btw call
|
||||
await btwCommand.action!(mockContext, 'second question');
|
||||
|
||||
// cancelBtw called again for the second invocation
|
||||
expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should return fallback text when response has no parts', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question: 'test question',
|
||||
answer: 'No response received.',
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return void immediately without blocking', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Only the pending setBtwItem called so far
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(1);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Now the completed setBtwItem has been called
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
let nonInteractiveContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message on success', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'the answer' }] } }],
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(
|
||||
nonInteractiveContext,
|
||||
'my question',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'btw> my question\nthe answer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error message on failure', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('network error'));
|
||||
|
||||
const result = await btwCommand.action!(
|
||||
nonInteractiveContext,
|
||||
'my question',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to answer btw question: network error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('acp mode', () => {
|
||||
let acpContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
acpContext = createMockCommandContext({
|
||||
executionMode: 'acp',
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stream_messages generator on success', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'streamed answer' }] } }],
|
||||
});
|
||||
|
||||
const result = (await btwCommand.action!(acpContext, 'my question')) as {
|
||||
type: string;
|
||||
messages: AsyncGenerator;
|
||||
};
|
||||
|
||||
expect(result.type).toBe('stream_messages');
|
||||
|
||||
const messages = [];
|
||||
for await (const msg of result.messages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ messageType: 'info', content: 'Thinking...' },
|
||||
{ messageType: 'info', content: 'btw> my question\nstreamed answer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should yield error message on failure', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('api failure'));
|
||||
|
||||
const result = (await btwCommand.action!(acpContext, 'my question')) as {
|
||||
type: string;
|
||||
messages: AsyncGenerator;
|
||||
};
|
||||
|
||||
const messages = [];
|
||||
for await (const msg of result.messages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ messageType: 'info', content: 'Thinking...' },
|
||||
{
|
||||
messageType: 'error',
|
||||
content: 'Failed to answer btw question: api failure',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
226
packages/cli/src/ui/commands/btwCommand.ts
Normal file
226
packages/cli/src/ui/commands/btwCommand.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { HistoryItemBtw } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
|
||||
function makeBtwPromptId(sessionId: string): string {
|
||||
return `${sessionId}########btw-${Date.now()}`;
|
||||
}
|
||||
|
||||
function formatBtwError(error: unknown): string {
|
||||
return t('Failed to answer btw question: {{error}}', {
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error || 'Unknown error'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make the ephemeral generateContent call and extract the answer.
|
||||
* Uses a snapshot of the current conversation history as context.
|
||||
*/
|
||||
async function askBtw(
|
||||
geminiClient: GeminiClient,
|
||||
model: string,
|
||||
question: string,
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
): Promise<string> {
|
||||
const history = geminiClient.getHistory();
|
||||
|
||||
const response = await geminiClient.generateContent(
|
||||
[
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `[Side question - answer briefly and concisely, this is a "by the way" question that doesn't need to be part of our main conversation]\n\n${question}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{},
|
||||
abortSignal,
|
||||
model,
|
||||
promptId,
|
||||
);
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
return (
|
||||
parts
|
||||
?.map((part) => part.text)
|
||||
.filter((text): text is string => typeof text === 'string')
|
||||
.join('') || t('No response received.')
|
||||
);
|
||||
}
|
||||
|
||||
export const btwCommand: SlashCommand = {
|
||||
name: 'btw',
|
||||
get description() {
|
||||
return t(
|
||||
'Ask a quick side question without affecting the main conversation',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const question = args.trim();
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
const abortSignal = context.abortSignal ?? new AbortController().signal;
|
||||
|
||||
if (!question) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Please provide a question. Usage: /btw <your question>'),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
const { ui } = context;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const model = config.getModel();
|
||||
const sessionId = config.getSessionId();
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('No model configured.'),
|
||||
};
|
||||
}
|
||||
|
||||
// ACP mode: return a stream_messages async generator
|
||||
if (executionMode === 'acp') {
|
||||
const btwPromptId = makeBtwPromptId(sessionId);
|
||||
const messages = async function* () {
|
||||
try {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Thinking...'),
|
||||
};
|
||||
|
||||
const answer = await askBtw(
|
||||
geminiClient,
|
||||
model,
|
||||
question,
|
||||
abortSignal,
|
||||
btwPromptId,
|
||||
);
|
||||
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: `btw> ${question}\n${answer}`,
|
||||
};
|
||||
} catch (error) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: formatBtwError(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { type: 'stream_messages', messages: messages() };
|
||||
}
|
||||
|
||||
// Non-interactive mode: return a simple message result
|
||||
if (executionMode === 'non_interactive') {
|
||||
try {
|
||||
const btwPromptId = makeBtwPromptId(sessionId);
|
||||
const answer = await askBtw(
|
||||
geminiClient,
|
||||
model,
|
||||
question,
|
||||
abortSignal,
|
||||
btwPromptId,
|
||||
);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `btw> ${question}\n${answer}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: formatBtwError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive mode: use dedicated btwItem state for the fixed bottom area.
|
||||
// This does NOT occupy pendingItem, so the main conversation is never blocked.
|
||||
|
||||
// Cancel any previous in-flight btw before starting a new one.
|
||||
ui.cancelBtw();
|
||||
|
||||
const btwAbortController = new AbortController();
|
||||
const btwSignal = btwAbortController.signal;
|
||||
ui.btwAbortControllerRef.current = btwAbortController;
|
||||
|
||||
const pendingItem: HistoryItemBtw = {
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question,
|
||||
answer: '',
|
||||
isPending: true,
|
||||
},
|
||||
};
|
||||
ui.setBtwItem(pendingItem);
|
||||
|
||||
// Fire-and-forget: run the API call in the background so the main
|
||||
// conversation is not blocked while waiting for the btw answer.
|
||||
const btwPromptId = makeBtwPromptId(sessionId);
|
||||
void askBtw(geminiClient, model, question, btwSignal, btwPromptId)
|
||||
.then((answer) => {
|
||||
if (btwSignal.aborted) return;
|
||||
|
||||
ui.btwAbortControllerRef.current = null;
|
||||
const completedItem: HistoryItemBtw = {
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question,
|
||||
answer,
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
ui.setBtwItem(completedItem);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (btwSignal.aborted) return;
|
||||
|
||||
ui.btwAbortControllerRef.current = null;
|
||||
ui.setBtwItem(null);
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: formatBtwError(error),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -59,6 +59,7 @@ describe('clearCommand', () => {
|
|||
}),
|
||||
getModel: () => 'test-model',
|
||||
getToolRegistry: () => undefined,
|
||||
getApprovalMode: () => 'default',
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
@ -108,6 +109,7 @@ describe('clearCommand', () => {
|
|||
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
|
||||
SessionStartSource.Clear,
|
||||
'test-model',
|
||||
expect.any(String), // permissionMode
|
||||
);
|
||||
|
||||
// SessionEnd should be called before SessionStart
|
||||
|
|
@ -138,6 +140,71 @@ describe('clearCommand', () => {
|
|||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should clear UI before resetChat for immediate responsiveness', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
const callOrder: string[] = [];
|
||||
(mockContext.ui.clear as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => {
|
||||
callOrder.push('ui.clear');
|
||||
},
|
||||
);
|
||||
mockResetChat.mockImplementation(async () => {
|
||||
callOrder.push('resetChat');
|
||||
});
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
// ui.clear should be called before resetChat for immediate UI feedback
|
||||
const clearIndex = callOrder.indexOf('ui.clear');
|
||||
const resetIndex = callOrder.indexOf('resetChat');
|
||||
expect(clearIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(resetIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(clearIndex).toBeLessThan(resetIndex);
|
||||
});
|
||||
|
||||
it('should not await hook events (fire-and-forget)', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
// Make hooks take a long time - they should not block
|
||||
let sessionEndResolved = false;
|
||||
let sessionStartResolved = false;
|
||||
mockFireSessionEndEvent.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
sessionEndResolved = true;
|
||||
resolve(undefined);
|
||||
}, 5000);
|
||||
}),
|
||||
);
|
||||
mockFireSessionStartEvent.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
sessionStartResolved = true;
|
||||
resolve(undefined);
|
||||
}, 5000);
|
||||
}),
|
||||
);
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
// The action should complete immediately without waiting for hooks
|
||||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
// Hooks should have been called but not necessarily resolved
|
||||
expect(mockFireSessionEndEvent).toHaveBeenCalled();
|
||||
expect(mockFireSessionStartEvent).toHaveBeenCalled();
|
||||
// Hooks should NOT have resolved yet since they have 5s timeouts
|
||||
expect(sessionEndResolved).toBe(false);
|
||||
expect(sessionStartResolved).toBe(false);
|
||||
});
|
||||
|
||||
it('should not attempt to reset chat if config service is not available', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
SessionStartSource,
|
||||
ToolNames,
|
||||
SkillTool,
|
||||
type PermissionMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
|
|
@ -26,14 +27,13 @@ export const clearCommand: SlashCommand = {
|
|||
const { config } = context.services;
|
||||
|
||||
if (config) {
|
||||
// Fire SessionEnd event before clearing (current session ends)
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionEndEvent(SessionEndReason.Clear);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
|
||||
}
|
||||
// Fire SessionEnd event (non-blocking to avoid UI lag)
|
||||
config
|
||||
.getHookSystem()
|
||||
?.fireSessionEndEvent(SessionEndReason.Clear)
|
||||
.catch((err) => {
|
||||
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
|
||||
});
|
||||
|
||||
const newSessionId = config.startNewSession();
|
||||
|
||||
|
|
@ -53,6 +53,9 @@ export const clearCommand: SlashCommand = {
|
|||
context.session.startNewSession(newSessionId);
|
||||
}
|
||||
|
||||
// Clear UI first for immediate responsiveness
|
||||
context.ui.clear();
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage(
|
||||
|
|
@ -65,21 +68,20 @@ export const clearCommand: SlashCommand = {
|
|||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
}
|
||||
|
||||
// Fire SessionStart event after clearing (new session starts)
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionStartEvent(
|
||||
SessionStartSource.Clear,
|
||||
config.getModel() ?? '',
|
||||
);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
}
|
||||
// Fire SessionStart event (non-blocking to avoid UI lag)
|
||||
config
|
||||
.getHookSystem()
|
||||
?.fireSessionStartEvent(
|
||||
SessionStartSource.Clear,
|
||||
config.getModel() ?? '',
|
||||
String(config.getApprovalMode()) as PermissionMode,
|
||||
)
|
||||
.catch((err) => {
|
||||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
});
|
||||
} else {
|
||||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
context.ui.clear();
|
||||
}
|
||||
|
||||
context.ui.clear();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
88
packages/cli/src/ui/commands/hooksCommand.test.ts
Normal file
88
packages/cli/src/ui/commands/hooksCommand.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { hooksCommand } from './hooksCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('hooksCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
let mockConfig: {
|
||||
getHookSystem: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock config with hook system
|
||||
mockConfig = {
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
getRegistry: vi.fn().mockReturnValue({
|
||||
getAllHooks: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should open hooks management dialog in interactive mode', async () => {
|
||||
const result = await hooksCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open hooks management dialog even if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await hooksCommand.action!(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open hooks management dialog even if hook system is not available', async () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(null);
|
||||
|
||||
const result = await hooksCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('should list hooks in non-interactive mode', async () => {
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
},
|
||||
executionMode: 'non_interactive',
|
||||
});
|
||||
|
||||
const result = await hooksCommand.action!(nonInteractiveContext, '');
|
||||
|
||||
// In non-interactive mode, it should return a message
|
||||
expect(result).toHaveProperty('type', 'message');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
|
@ -20,13 +20,13 @@ import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
|
|||
function formatHookSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'project':
|
||||
return 'Project';
|
||||
return t('Project');
|
||||
case 'user':
|
||||
return 'User';
|
||||
return t('User');
|
||||
case 'system':
|
||||
return 'System';
|
||||
return t('System');
|
||||
case 'extensions':
|
||||
return 'Extension';
|
||||
return t('Extension');
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ function formatHookSource(source: string): string {
|
|||
* Format hook status for display
|
||||
*/
|
||||
function formatHookStatus(enabled: boolean): string {
|
||||
return enabled ? '✓ Enabled' : '✗ Disabled';
|
||||
return enabled ? t('✓ Enabled') : t('✗ Disabled');
|
||||
}
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
|
|
@ -114,209 +114,27 @@ const listCommand: SlashCommand = {
|
|||
},
|
||||
};
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
get description() {
|
||||
return t('Enable a disabled hook');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const hookName = args.trim();
|
||||
if (!hookName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Please specify a hook name. Usage: /hooks enable <hook-name>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Hooks are not enabled.'),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
registry.setHookEnabled(hookName, true);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Hook "{{name}}" has been enabled for this session.', {
|
||||
name: hookName,
|
||||
}),
|
||||
};
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) return [];
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
// Return disabled hooks for enable command (deduplicated by name)
|
||||
const disabledHookNames = allHooks
|
||||
.filter((hook) => !hook.enabled)
|
||||
.map((hook) => hook.config.name || hook.config.command || '')
|
||||
.filter((name) => name && name.startsWith(partialArg));
|
||||
return [...new Set(disabledHookNames)];
|
||||
},
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
get description() {
|
||||
return t('Disable an active hook');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const hookName = args.trim();
|
||||
if (!hookName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Please specify a hook name. Usage: /hooks disable <hook-name>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Hooks are not enabled.'),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
registry.setHookEnabled(hookName, false);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Hook "{{name}}" has been disabled for this session.', {
|
||||
name: hookName,
|
||||
}),
|
||||
};
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) return [];
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
// Return enabled hooks for disable command (deduplicated by name)
|
||||
const enabledHookNames = allHooks
|
||||
.filter((hook) => hook.enabled)
|
||||
.map((hook) => hook.config.name || hook.config.command || '')
|
||||
.filter((name) => name && name.startsWith(partialArg));
|
||||
return [...new Set(enabledHookNames)];
|
||||
},
|
||||
};
|
||||
|
||||
export const hooksCommand: SlashCommand = {
|
||||
name: 'hooks',
|
||||
get description() {
|
||||
return t('Manage Qwen Code hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, enableCommand, disableCommand],
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
// If no subcommand provided, show list
|
||||
if (!args.trim()) {
|
||||
const result = await listCommand.action?.(context, '');
|
||||
return result ?? { type: 'message', messageType: 'info', content: '' };
|
||||
// In interactive mode, open the hooks dialog
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
if (executionMode === 'interactive') {
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
};
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = args.trim().split(/\s+/);
|
||||
const subArgs = rest.join(' ');
|
||||
|
||||
let result: SlashCommandActionReturn | void;
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'list':
|
||||
result = await listCommand.action?.(context, subArgs);
|
||||
break;
|
||||
case 'enable':
|
||||
result = await enableCommand.action?.(context, subArgs);
|
||||
break;
|
||||
case 'disable':
|
||||
result = await disableCommand.action?.(context, subArgs);
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
|
||||
{
|
||||
cmd: subcommand,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
// In non-interactive mode, list hooks
|
||||
const result = await listCommand.action?.(context, args);
|
||||
return result ?? { type: 'message', messageType: 'info', content: '' };
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const subcommands = ['list', 'enable', 'disable'];
|
||||
const parts = partialArg.split(/\s+/);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
// Complete subcommand
|
||||
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
|
||||
}
|
||||
|
||||
// Complete subcommand arguments
|
||||
const [subcommand, ...rest] = parts;
|
||||
const subArgs = rest.join(' ');
|
||||
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'enable':
|
||||
return enableCommand.completion?.(context, subArgs) ?? [];
|
||||
case 'disable':
|
||||
return disableCommand.completion?.(context, subArgs) ?? [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -168,6 +168,116 @@ describe('memoryCommand', () => {
|
|||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to AGENTS.md when QWEN.md does not exist for --project', async () => {
|
||||
const projectCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--project',
|
||||
);
|
||||
if (!projectCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
||||
mockReadFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath.endsWith('AGENTS.md')) return 'agents memory content';
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await projectCommand.action(mockContext, '');
|
||||
|
||||
const expectedPath = path.join('/test/project', 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('agents memory content'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to AGENTS.md when QWEN.md does not exist for --global', async () => {
|
||||
const globalCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--global',
|
||||
);
|
||||
if (!globalCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
||||
mockReadFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath.endsWith('AGENTS.md')) return 'global agents memory';
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await globalCommand.action(mockContext, '');
|
||||
|
||||
const expectedPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('global agents memory'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show content from both QWEN.md and AGENTS.md for --project when both exist', async () => {
|
||||
const projectCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--project',
|
||||
);
|
||||
if (!projectCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
||||
mockReadFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath.endsWith('QWEN.md')) return 'qwen memory';
|
||||
if (filePath.endsWith('AGENTS.md')) return 'agents memory';
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await projectCommand.action(mockContext, '');
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
path.join('/test/project', 'QWEN.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
path.join('/test/project', 'AGENTS.md'),
|
||||
'utf-8',
|
||||
);
|
||||
const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0];
|
||||
expect(addItemCall.text).toContain('qwen memory');
|
||||
expect(addItemCall.text).toContain('agents memory');
|
||||
});
|
||||
|
||||
it('should show content from both files for --global when both exist', async () => {
|
||||
const globalCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--global',
|
||||
);
|
||||
if (!globalCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
||||
mockReadFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath.endsWith('QWEN.md')) return 'global qwen memory';
|
||||
if (filePath.endsWith('AGENTS.md')) return 'global agents memory';
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await globalCommand.action(mockContext, '');
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
path.join('/home/user', QWEN_DIR, 'QWEN.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
path.join('/home/user', QWEN_DIR, 'AGENTS.md'),
|
||||
'utf-8',
|
||||
);
|
||||
const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0];
|
||||
expect(addItemCall.text).toContain('global qwen memory');
|
||||
expect(addItemCall.text).toContain('global agents memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory add', () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import {
|
||||
getErrorMessage,
|
||||
getCurrentGeminiMdFilename,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -18,6 +18,28 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
|||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Read all existing memory files from the configured filenames in a directory.
|
||||
* Returns an array of found files with their paths and contents.
|
||||
*/
|
||||
async function findAllExistingMemoryFiles(
|
||||
dir: string,
|
||||
): Promise<Array<{ filePath: string; content: string }>> {
|
||||
const results: Array<{ filePath: string; content: string }> = [];
|
||||
for (const filename of getAllGeminiMdFilenames()) {
|
||||
const filePath = path.join(dir, filename);
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
if (content.trim().length > 0) {
|
||||
results.push({ filePath, content });
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, try next
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
get description() {
|
||||
|
|
@ -56,37 +78,27 @@ export const memoryCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
const workingDir =
|
||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||
const projectMemoryPath = path.join(
|
||||
workingDir,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const memoryContent = await fs.readFile(
|
||||
projectMemoryPath,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const messageContent =
|
||||
memoryContent.trim().length > 0
|
||||
? t(
|
||||
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
|
||||
{
|
||||
path: projectMemoryPath,
|
||||
content: memoryContent,
|
||||
},
|
||||
)
|
||||
: t('Project memory is currently empty.');
|
||||
const workingDir =
|
||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||
const results = await findAllExistingMemoryFiles(workingDir);
|
||||
|
||||
if (results.length > 0) {
|
||||
const combined = results
|
||||
.map((r) =>
|
||||
t(
|
||||
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
|
||||
{ path: r.filePath, content: r.content },
|
||||
),
|
||||
)
|
||||
.join('\n\n');
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: messageContent,
|
||||
text: combined,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (_error) {
|
||||
} else {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
|
|
@ -106,32 +118,25 @@ export const memoryCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
const globalMemoryPath = path.join(
|
||||
os.homedir(),
|
||||
QWEN_DIR,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const globalMemoryContent = await fs.readFile(
|
||||
globalMemoryPath,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const messageContent =
|
||||
globalMemoryContent.trim().length > 0
|
||||
? t('Global memory content:\n\n---\n{{content}}\n---', {
|
||||
content: globalMemoryContent,
|
||||
})
|
||||
: t('Global memory is currently empty.');
|
||||
const globalDir = path.join(os.homedir(), QWEN_DIR);
|
||||
const results = await findAllExistingMemoryFiles(globalDir);
|
||||
|
||||
if (results.length > 0) {
|
||||
const combined = results
|
||||
.map((r) =>
|
||||
t('Global memory content:\n\n---\n{{content}}\n---', {
|
||||
content: r.content,
|
||||
}),
|
||||
)
|
||||
.join('\n\n');
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: messageContent,
|
||||
text: combined,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (_error) {
|
||||
} else {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import type { Content, PartListUnion } from '@google/genai';
|
||||
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
HistoryItemBtw,
|
||||
ConfirmationRequest,
|
||||
} from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
|
@ -66,6 +67,14 @@ export interface CommandContext {
|
|||
* @param item The history item to display as pending, or `null` to clear.
|
||||
*/
|
||||
setPendingItem: (item: HistoryItemWithoutId | null) => void;
|
||||
/** The current btw side-question item rendered in the fixed bottom area. */
|
||||
btwItem: HistoryItemBtw | null;
|
||||
/** Sets the btw item independently of the main pendingItem. */
|
||||
setBtwItem: (item: HistoryItemBtw | null) => void;
|
||||
/** Cancels a pending btw (aborts the in-flight API call and clears the btw area). */
|
||||
cancelBtw: () => void;
|
||||
/** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */
|
||||
btwAbortControllerRef: MutableRefObject<AbortController | null>;
|
||||
/**
|
||||
* Loads a new set of history items, replacing the current history.
|
||||
*
|
||||
|
|
@ -155,6 +164,7 @@ export interface OpenDialogActionReturn {
|
|||
| 'approval-mode'
|
||||
| 'resume'
|
||||
| 'extensions_manage'
|
||||
| 'hooks'
|
||||
| 'mcp';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
|||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
|
||||
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
|
||||
import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
|
|
@ -351,6 +352,9 @@ export const DialogManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isHooksDialogOpen) {
|
||||
return <HooksManagementDialog onClose={uiActions.closeHooksDialog} />;
|
||||
}
|
||||
if (uiState.isMcpDialogOpen) {
|
||||
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { McpStatus } from './views/McpStatus.js';
|
|||
import { ContextUsage } from './views/ContextUsage.js';
|
||||
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
|
||||
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
||||
import { BtwMessage } from './messages/BtwMessage.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
|
|
@ -226,6 +227,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'insight_progress' && (
|
||||
<InsightProgressMessage progress={itemForDisplay.progress} />
|
||||
)}
|
||||
{itemForDisplay.type === 'btw' && itemForDisplay.btw && (
|
||||
<BtwMessage btw={itemForDisplay.btw} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import type { HookEventDisplayInfo, HookConfigDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
border: {
|
||||
default: 'gray',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HookConfigDetailStep', () => {
|
||||
const createMockHookEvent = (): HookEventDisplayInfo => ({
|
||||
event: HookEventName.Stop,
|
||||
shortDescription: 'Right before Qwen Code concludes its response',
|
||||
description: '',
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'stdout/stderr not shown' },
|
||||
{
|
||||
code: 2,
|
||||
description: 'show stderr to model and continue conversation',
|
||||
},
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
configs: [],
|
||||
});
|
||||
|
||||
const createMockHookConfig = (
|
||||
source: HooksConfigSource = HooksConfigSource.User,
|
||||
sourceDisplay = 'User Settings',
|
||||
sourcePath?: string,
|
||||
): HookConfigDisplayInfo => ({
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
},
|
||||
source,
|
||||
sourceDisplay,
|
||||
sourcePath,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render hook details title', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Hook details');
|
||||
});
|
||||
|
||||
it('should render event name', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Event:');
|
||||
expect(lastFrame()).toContain(HookEventName.Stop);
|
||||
});
|
||||
|
||||
it('should render hook type', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Type:');
|
||||
expect(lastFrame()).toContain('command');
|
||||
});
|
||||
|
||||
it('should render source for User Settings', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.User);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Source:');
|
||||
expect(lastFrame()).toContain('User Settings');
|
||||
});
|
||||
|
||||
it('should render source for Local Settings', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.Project);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Local Settings');
|
||||
});
|
||||
|
||||
it('should render source for Extensions with path', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(
|
||||
HooksConfigSource.Extensions,
|
||||
'ralph-wiggum',
|
||||
'/Users/test/.qwen/extensions/ralph-wiggum',
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Extensions');
|
||||
expect(lastFrame()).toContain('/Users/test/.qwen/extensions/ralph-wiggum');
|
||||
});
|
||||
|
||||
it('should render Extension field for extensions', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(
|
||||
HooksConfigSource.Extensions,
|
||||
'ralph-wiggum',
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Extension:');
|
||||
expect(lastFrame()).toContain('ralph-wiggum');
|
||||
});
|
||||
|
||||
it('should not render Extension field for non-extensions', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.User);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
// Should not have Extension label for User Settings
|
||||
const output = lastFrame();
|
||||
const extensionMatch = output?.match(/Extension:/g);
|
||||
expect(extensionMatch).toBeNull();
|
||||
});
|
||||
|
||||
it('should render command', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Command:');
|
||||
expect(lastFrame()).toContain('/path/to/hook.sh');
|
||||
});
|
||||
|
||||
it('should render hook name if present', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig: HookConfigDisplayInfo = {
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
name: 'My Hook',
|
||||
},
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Name:');
|
||||
expect(lastFrame()).toContain('My Hook');
|
||||
});
|
||||
|
||||
it('should render hook description if present', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig: HookConfigDisplayInfo = {
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
description: 'A test hook',
|
||||
},
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Desc:');
|
||||
expect(lastFrame()).toContain('A test hook');
|
||||
});
|
||||
|
||||
it('should render help text', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('To modify or remove this hook');
|
||||
});
|
||||
|
||||
it('should render Esc hint', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Esc to go back');
|
||||
});
|
||||
|
||||
it('should handle different event types', () => {
|
||||
const events = [
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const hookEvent: HookEventDisplayInfo = {
|
||||
event,
|
||||
shortDescription: 'Test',
|
||||
description: '',
|
||||
exitCodes: [],
|
||||
configs: [],
|
||||
};
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
167
packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Normal file
167
packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookConfigDisplayInfo, HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HookConfigDetailStepProps {
|
||||
hookEvent: HookEventDisplayInfo;
|
||||
hookConfig: HookConfigDisplayInfo;
|
||||
}
|
||||
|
||||
export function HookConfigDetailStep({
|
||||
hookEvent,
|
||||
hookConfig,
|
||||
}: HookConfigDetailStepProps): React.JSX.Element {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Get source display
|
||||
const getSourceDisplay = (): string => {
|
||||
switch (hookConfig.source) {
|
||||
case HooksConfigSource.Project:
|
||||
return t('Local Settings');
|
||||
case HooksConfigSource.User:
|
||||
return t('User Settings');
|
||||
case HooksConfigSource.System:
|
||||
return t('System Settings');
|
||||
case HooksConfigSource.Extensions:
|
||||
return t('Extensions');
|
||||
default:
|
||||
return hookConfig.source;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this is from an extension
|
||||
const isFromExtension = hookConfig.source === HooksConfigSource.Extensions;
|
||||
|
||||
// Get hook type display
|
||||
const getHookTypeDisplay = (): string => {
|
||||
switch (hookConfig.config.type) {
|
||||
case 'command':
|
||||
return 'command';
|
||||
default:
|
||||
return hookConfig.config.type;
|
||||
}
|
||||
};
|
||||
|
||||
// Get command to display
|
||||
const getCommand = (): string => {
|
||||
if (hookConfig.config.type === 'command') {
|
||||
return hookConfig.config.command;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calculate box width for command display
|
||||
const commandBoxWidth = Math.min(terminalWidth - 6, 80);
|
||||
|
||||
// Label width for alignment (Extension: is the longest label)
|
||||
const labelWidth = 12;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Hook details')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Event */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Event:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookEvent.event}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Type */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Type:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{getHookTypeDisplay()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Source */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{getSourceDisplay()}</Text>
|
||||
{hookConfig.sourcePath && (
|
||||
<Text color={theme.text.secondary}> ({hookConfig.sourcePath})</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Extension name (only for extensions) */}
|
||||
{isFromExtension && hookConfig.sourceDisplay && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Extension:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookConfig.sourceDisplay}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Name (if exists) */}
|
||||
{hookConfig.config.name && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Name:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookConfig.config.name}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{hookConfig.config.description && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Desc:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{hookConfig.config.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Command */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Command:')}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Command box */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
width={commandBoxWidth}
|
||||
>
|
||||
<Text color={theme.text.primary}>{getCommand()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
228
packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
Normal file
228
packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HookDetailStep } from './HookDetailStep.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HookDetailStep', () => {
|
||||
const createMockHookInfo = (
|
||||
event: HookEventName,
|
||||
configCount = 0,
|
||||
hasDescription = true,
|
||||
): HookEventDisplayInfo => ({
|
||||
event,
|
||||
shortDescription: `Short description for ${event}`,
|
||||
description: hasDescription ? `Detailed description for ${event}` : '',
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'Success' },
|
||||
{ code: 2, description: 'Block' },
|
||||
],
|
||||
configs: Array(configCount)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
config: { command: `hook-command-${i}`, type: HookType.Command },
|
||||
source:
|
||||
i % 2 === 0 ? HooksConfigSource.User : HooksConfigSource.Project,
|
||||
sourceDisplay: i % 2 === 0 ? 'User Settings' : 'Local Settings',
|
||||
enabled: true,
|
||||
})),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render hook event name as title', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(HookEventName.PreToolUse);
|
||||
});
|
||||
|
||||
it('should render description when present', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 0, true);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Detailed description for PreToolUse');
|
||||
});
|
||||
|
||||
it('should not render description section when empty', () => {
|
||||
const hook = createMockHookInfo(HookEventName.Stop, 0, false);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
// Stop event has empty description
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(HookEventName.Stop);
|
||||
});
|
||||
|
||||
it('should render exit codes', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Exit codes');
|
||||
expect(output).toContain('0');
|
||||
expect(output).toContain('Success');
|
||||
expect(output).toContain('2');
|
||||
expect(output).toContain('Block');
|
||||
});
|
||||
|
||||
it('should show empty state when no configs', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 0);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('No hooks configured for this event');
|
||||
expect(output).toContain('To add hooks, edit settings.json');
|
||||
});
|
||||
|
||||
it('should show configured hooks list when configs exist', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Configured hooks');
|
||||
expect(output).toContain('[command]');
|
||||
expect(output).toContain('hook-command-0');
|
||||
expect(output).toContain('hook-command-1');
|
||||
});
|
||||
|
||||
it('should show source display for each config', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('User Settings');
|
||||
expect(output).toContain('Local Settings');
|
||||
});
|
||||
|
||||
it('should show selection indicator for first config', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 3);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('❯');
|
||||
});
|
||||
|
||||
it('should show keyboard hint for going back', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Esc to go back');
|
||||
});
|
||||
|
||||
it('should render with multiple configs', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PostToolUse, 5);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1.');
|
||||
expect(output).toContain('2.');
|
||||
expect(output).toContain('3.');
|
||||
expect(output).toContain('4.');
|
||||
expect(output).toContain('5.');
|
||||
});
|
||||
|
||||
it('should handle hook with no exit codes', () => {
|
||||
const hook: HookEventDisplayInfo = {
|
||||
event: HookEventName.PreToolUse,
|
||||
shortDescription: 'Test',
|
||||
description: 'Test description',
|
||||
exitCodes: [],
|
||||
configs: [],
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Exit codes');
|
||||
});
|
||||
|
||||
it('should handle different hook event types', () => {
|
||||
const events = [
|
||||
HookEventName.Stop,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
HookEventName.SessionEnd,
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const hook = createMockHookInfo(event, 1);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
150
packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Normal file
150
packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { getTranslatedSourceDisplayMap } from './constants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HookDetailStepProps {
|
||||
hook: HookEventDisplayInfo;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export function HookDetailStep({
|
||||
hook,
|
||||
selectedIndex,
|
||||
}: HookDetailStepProps): React.JSX.Element {
|
||||
const hasConfigs = hook.configs.length > 0;
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Get translated source display map
|
||||
const sourceDisplayMap = getTranslatedSourceDisplayMap();
|
||||
|
||||
// Calculate column widths (command: 70%, source: 30%)
|
||||
const commandWidth = Math.floor(terminalWidth * 0.65);
|
||||
const sourceWidth = Math.floor(terminalWidth * 0.3);
|
||||
|
||||
// Get source display for config list
|
||||
const getConfigSourceDisplay = (config: {
|
||||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
}): string => {
|
||||
if (config.source === HooksConfigSource.Extensions) {
|
||||
// For extensions, sourceDisplay is the extension name
|
||||
return `${sourceDisplayMap[HooksConfigSource.Extensions]} (${config.sourceDisplay})`;
|
||||
}
|
||||
return sourceDisplayMap[config.source] || config.source;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{hook.event}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
{hook.description && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>{hook.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Exit codes */}
|
||||
{hook.exitCodes.length > 0 && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Exit codes:')}
|
||||
</Text>
|
||||
{hook.exitCodes.map((ec, index) => (
|
||||
<Box key={index}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` ${ec.code}: ${ec.description}`}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} />
|
||||
|
||||
{/* Configs or empty state */}
|
||||
{hasConfigs ? (
|
||||
<>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Configured hooks:')}
|
||||
</Text>
|
||||
{hook.configs.map((config, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const sourceDisplay = getConfigSourceDisplay(config);
|
||||
const command =
|
||||
config.config.type === 'command' ? config.config.command : '';
|
||||
const hookType = config.config.type;
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
{/* Left column: selector + command */}
|
||||
<Box width={commandWidth}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
wrap="wrap"
|
||||
>
|
||||
{`${index + 1}. [${hookType}] ${command}`}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Spacer between columns */}
|
||||
<Box width={2} />
|
||||
{/* Right column: source */}
|
||||
<Box width={sourceWidth}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{sourceDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to select · Esc to go back')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No hooks configured for this event.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('To add hooks, edit settings.json directly or ask Qwen.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
197
packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
Normal file
197
packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
HooksConfigSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HooksListStep } from './HooksListStep.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string, options?: { count?: string }) => {
|
||||
// Handle pluralization
|
||||
if (key === '{{count}} hook configured' && options?.count) {
|
||||
return `${options.count} hook configured`;
|
||||
}
|
||||
if (key === '{{count}} hooks configured' && options?.count) {
|
||||
return `${options.count} hooks configured`;
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HooksListStep', () => {
|
||||
const createMockHookInfo = (
|
||||
event: HookEventName,
|
||||
configCount = 0,
|
||||
): HookEventDisplayInfo => ({
|
||||
event,
|
||||
shortDescription: `Description for ${event}`,
|
||||
description: `Detailed description for ${event}`,
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'Success' },
|
||||
{ code: 2, description: 'Block' },
|
||||
],
|
||||
configs: Array(configCount)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
config: { command: `hook-${i}`, type: HookType.Command },
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
})),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render empty state when no hooks', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={[]} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('No hook events found');
|
||||
});
|
||||
|
||||
it('should render list of hooks', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
createMockHookInfo(HookEventName.PostToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Hooks');
|
||||
expect(output).toContain(HookEventName.PreToolUse);
|
||||
expect(output).toContain(HookEventName.PostToolUse);
|
||||
});
|
||||
|
||||
it('should show config count for hooks with configs', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse, 3),
|
||||
createMockHookInfo(HookEventName.PostToolUse, 0),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('(3)');
|
||||
expect(output).not.toContain('(0)');
|
||||
});
|
||||
|
||||
it('should show singular form for single hook', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse, 1),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1 hook configured');
|
||||
});
|
||||
|
||||
it('should show read-only message', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('read-only');
|
||||
expect(output).toContain('settings.json');
|
||||
});
|
||||
|
||||
it('should show keyboard hints', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Enter to select');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should show selection indicator for first item', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
createMockHookInfo(HookEventName.PostToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('❯');
|
||||
});
|
||||
|
||||
it('should display hook short descriptions', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Description for PreToolUse');
|
||||
});
|
||||
|
||||
it('should pad index numbers based on total count', () => {
|
||||
const hooks: HookEventDisplayInfo[] = Array(10)
|
||||
.fill(null)
|
||||
.map((_, i) => createMockHookInfo(`${i}` as HookEventName));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(' 1.');
|
||||
expect(output).toContain('10.');
|
||||
});
|
||||
});
|
||||
103
packages/cli/src/ui/components/hooks/HooksListStep.tsx
Normal file
103
packages/cli/src/ui/components/hooks/HooksListStep.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HooksListStepProps {
|
||||
hooks: HookEventDisplayInfo[];
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export function HooksListStep({
|
||||
hooks,
|
||||
selectedIndex,
|
||||
}: HooksListStepProps): React.JSX.Element {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Calculate responsive width for hook name column (min 20, max 35)
|
||||
const hookNameWidth = Math.min(
|
||||
35,
|
||||
Math.max(20, Math.floor(terminalWidth * 0.25)),
|
||||
);
|
||||
|
||||
if (hooks.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{t('No hook events found.')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate total configured hooks
|
||||
const totalConfigured = hooks.reduce(
|
||||
(sum, hook) => sum + hook.configs.length,
|
||||
0,
|
||||
);
|
||||
|
||||
// Get the correct plural/singular form
|
||||
const hooksConfiguredText =
|
||||
totalConfigured === 1
|
||||
? t('{{count}} hook configured', { count: String(totalConfigured) })
|
||||
: t('{{count}} hooks configured', { count: String(totalConfigured) });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Hooks')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{` · ${hooksConfiguredText}`}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hooks.map((hook, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const configCount = hook.configs.length;
|
||||
const maxDigits = String(hooks.length).length;
|
||||
const paddedIndex = String(index + 1).padStart(maxDigits);
|
||||
|
||||
return (
|
||||
<Box key={hook.event}>
|
||||
<Box minWidth={2}>
|
||||
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={hookNameWidth}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
>
|
||||
{paddedIndex}. {hook.event}
|
||||
{configCount > 0 && (
|
||||
<Text color={theme.status.success}> ({configCount})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}>{hook.shortDescription}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to select · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HooksManagementDialog } from './HooksManagementDialog.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string, options?: { count?: string }) => {
|
||||
// Handle pluralization
|
||||
if (key === '{{count}} hook configured' && options?.count) {
|
||||
return `${options.count} hook configured`;
|
||||
}
|
||||
if (key === '{{count}} hooks configured' && options?.count) {
|
||||
return `${options.count} hooks configured`;
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock useConfig
|
||||
vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../contexts/ConfigContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useConfig: vi.fn(() => ({
|
||||
getExtensions: vi.fn(() => []),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock loadSettings
|
||||
vi.mock('../../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(() => ({
|
||||
forScope: vi.fn(() => ({ settings: {} })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
},
|
||||
border: {
|
||||
default: 'gray',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock createDebugLogger
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createDebugLogger: vi.fn(() => ({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('HooksManagementDialog', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render loading state initially', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Loading hooks');
|
||||
});
|
||||
|
||||
it('should render with border', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// The dialog should have a border (rendered as box-drawing characters)
|
||||
const output = lastFrame();
|
||||
expect(output).toBeTruthy();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle empty hooks list gracefully', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show 0 hooks configured when no hooks are configured
|
||||
expect(output).toContain('0 hooks configured');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
421
packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
Normal file
421
packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { loadSettings, SettingScope } from '../../../config/settings.js';
|
||||
import {
|
||||
HooksConfigSource,
|
||||
type HookDefinition,
|
||||
type HookConfig,
|
||||
createDebugLogger,
|
||||
HOOKS_CONFIG_FIELDS,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HooksManagementDialogProps,
|
||||
HookEventDisplayInfo,
|
||||
} from './types.js';
|
||||
import { HOOKS_MANAGEMENT_STEPS } from './types.js';
|
||||
import { HooksListStep } from './HooksListStep.js';
|
||||
import { HookDetailStep } from './HookDetailStep.js';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import {
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
getTranslatedSourceDisplayMap,
|
||||
createEmptyHookEventInfo,
|
||||
} from './constants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_DIALOG');
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid HookConfig
|
||||
*/
|
||||
function isValidHookConfig(config: unknown): config is HookConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'type' in config &&
|
||||
'command' in config &&
|
||||
typeof (config as HookConfig).command === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid HookDefinition
|
||||
*/
|
||||
function isValidHookDefinition(def: unknown): def is HookDefinition {
|
||||
if (typeof def !== 'object' || def === null) {
|
||||
return false;
|
||||
}
|
||||
const obj = def as Record<string, unknown>;
|
||||
// hooks array is required
|
||||
if (!('hooks' in obj) || !Array.isArray(obj['hooks'])) {
|
||||
return false;
|
||||
}
|
||||
// Validate each hook config in the array
|
||||
for (const hook of obj['hooks']) {
|
||||
if (!isValidHookConfig(hook)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// matcher is optional but must be a string if present
|
||||
if ('matcher' in obj && typeof obj['matcher'] !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// sequential is optional but must be a boolean if present
|
||||
if ('sequential' in obj && typeof obj['sequential'] !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid hooks record
|
||||
*/
|
||||
function isValidHooksRecord(
|
||||
hooks: unknown,
|
||||
): hooks is Record<string, HookDefinition[]> {
|
||||
if (typeof hooks !== 'object' || hooks === null) {
|
||||
return false;
|
||||
}
|
||||
const record = hooks as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
// Skip non-event configuration fields
|
||||
if (HOOKS_CONFIG_FIELDS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
for (const def of value) {
|
||||
if (!isValidHookDefinition(def)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function HooksManagementDialog({
|
||||
onClose,
|
||||
}: HooksManagementDialogProps): React.JSX.Element {
|
||||
const config = useConfig();
|
||||
const { columns: width } = useTerminalSize();
|
||||
const boxWidth = width - 4;
|
||||
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
|
||||
]);
|
||||
const [selectedHookIndex, setSelectedHookIndex] = useState<number>(-1);
|
||||
const [selectedConfigIndex, setSelectedConfigIndex] = useState<number>(-1);
|
||||
// Track selected index within each step for keyboard navigation
|
||||
const [listSelectedIndex, setListSelectedIndex] = useState<number>(0);
|
||||
const [detailSelectedIndex, setDetailSelectedIndex] = useState<number>(0);
|
||||
const [hooks, setHooks] = useState<HookEventDisplayInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Current step
|
||||
const currentStep =
|
||||
navigationStack[navigationStack.length - 1] ||
|
||||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST;
|
||||
|
||||
// Selected hook event
|
||||
const selectedHook = useMemo(() => {
|
||||
if (selectedHookIndex >= 0 && selectedHookIndex < hooks.length) {
|
||||
return hooks[selectedHookIndex];
|
||||
}
|
||||
return null;
|
||||
}, [hooks, selectedHookIndex]);
|
||||
|
||||
// Centralized keyboard handler
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (isLoading || loadError) {
|
||||
// Allow Escape to close even during loading/error states
|
||||
if (key.name === 'escape') {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
|
||||
if (key.name === 'up') {
|
||||
setListSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setListSelectedIndex((prev) =>
|
||||
Math.min(hooks.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.name === 'return') {
|
||||
if (hooks.length > 0 && listSelectedIndex >= 0) {
|
||||
setSelectedHookIndex(listSelectedIndex);
|
||||
setSelectedConfigIndex(-1);
|
||||
setDetailSelectedIndex(0);
|
||||
setNavigationStack((prev) => [
|
||||
...prev,
|
||||
HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL,
|
||||
]);
|
||||
}
|
||||
} else if (key.name === 'escape') {
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
|
||||
if (key.name === 'escape') {
|
||||
handleNavigateBack();
|
||||
} else if (selectedHook && selectedHook.configs.length > 0) {
|
||||
if (key.name === 'up') {
|
||||
setDetailSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setDetailSelectedIndex((prev) =>
|
||||
Math.min(selectedHook.configs.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.name === 'return') {
|
||||
setSelectedConfigIndex(detailSelectedIndex);
|
||||
setNavigationStack((prev) => [
|
||||
...prev,
|
||||
HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL,
|
||||
]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
|
||||
if (key.name === 'escape') {
|
||||
handleNavigateBack();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// No action for unknown steps
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Load hooks data
|
||||
const fetchHooksData = useCallback((): HookEventDisplayInfo[] => {
|
||||
if (!config) return [];
|
||||
|
||||
const settings = loadSettings();
|
||||
const userSettings = settings.forScope(SettingScope.User).settings;
|
||||
const workspaceSettings = settings.forScope(
|
||||
SettingScope.Workspace,
|
||||
).settings;
|
||||
|
||||
// Get translated source display map
|
||||
const sourceDisplayMap = getTranslatedSourceDisplayMap();
|
||||
|
||||
const result: HookEventDisplayInfo[] = [];
|
||||
|
||||
for (const eventName of DISPLAY_HOOK_EVENTS) {
|
||||
const hookInfo = createEmptyHookEventInfo(eventName);
|
||||
|
||||
// Get hooks from user settings (with type validation)
|
||||
const userSettingsRecord = userSettings as Record<string, unknown>;
|
||||
const userHooksRaw = userSettingsRecord?.['hooks'];
|
||||
if (isValidHooksRecord(userHooksRaw) && userHooksRaw[eventName]) {
|
||||
for (const def of userHooksRaw[eventName]) {
|
||||
for (const hookConfig of def.hooks) {
|
||||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: sourceDisplayMap[HooksConfigSource.User],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get hooks from workspace settings (with type validation)
|
||||
const workspaceSettingsRecord = workspaceSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const workspaceHooksRaw = workspaceSettingsRecord?.['hooks'];
|
||||
if (
|
||||
isValidHooksRecord(workspaceHooksRaw) &&
|
||||
workspaceHooksRaw[eventName]
|
||||
) {
|
||||
for (const def of workspaceHooksRaw[eventName]) {
|
||||
for (const hookConfig of def.hooks) {
|
||||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.Project,
|
||||
sourceDisplay: sourceDisplayMap[HooksConfigSource.Project],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get hooks from extensions (with type validation)
|
||||
const extensions = config.getExtensions() || [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.isActive && extension.hooks?.[eventName]) {
|
||||
const extensionHooks = extension.hooks[eventName];
|
||||
if (Array.isArray(extensionHooks)) {
|
||||
for (const def of extensionHooks) {
|
||||
if (isValidHookDefinition(def)) {
|
||||
for (const hookConfig of def.hooks) {
|
||||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.Extensions,
|
||||
sourceDisplay: extension.name,
|
||||
sourcePath: extension.path,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(hookInfo);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [config]);
|
||||
|
||||
// Load hooks data on initial render
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const hooksData = fetchHooksData();
|
||||
if (!cancelled) {
|
||||
setHooks(hooksData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
debugLogger.error('Error loading hooks:', error);
|
||||
setLoadError(
|
||||
error instanceof Error ? error.message : 'Failed to load hooks',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchHooksData]);
|
||||
|
||||
// Navigation handler for going back
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
onClose();
|
||||
return prev;
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}, [onClose]);
|
||||
|
||||
// Selected hook config
|
||||
const selectedConfig = useMemo(() => {
|
||||
if (
|
||||
selectedHook &&
|
||||
selectedConfigIndex >= 0 &&
|
||||
selectedConfigIndex < selectedHook.configs.length
|
||||
) {
|
||||
return selectedHook.configs[selectedConfigIndex];
|
||||
}
|
||||
return null;
|
||||
}, [selectedHook, selectedConfigIndex]);
|
||||
|
||||
// Render based on current step
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{t('Loading hooks...')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.status.error}>{t('Error loading hooks:')}</Text>
|
||||
<Text color={theme.text.secondary}>{loadError}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Escape to close')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
|
||||
return (
|
||||
<HooksListStep hooks={hooks} selectedIndex={listSelectedIndex} />
|
||||
);
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
|
||||
if (selectedHook) {
|
||||
return (
|
||||
<HookDetailStep
|
||||
hook={selectedHook}
|
||||
selectedIndex={detailSelectedIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{t('No hook selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
|
||||
if (selectedHook && selectedConfig) {
|
||||
return (
|
||||
<HookConfigDetailStep
|
||||
hookEvent={selectedHook}
|
||||
hookConfig={selectedConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No hook config selected')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={boxWidth}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
219
packages/cli/src/ui/components/hooks/constants.test.ts
Normal file
219
packages/cli/src/ui/components/hooks/constants.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookEventName, HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import {
|
||||
getHookExitCodes,
|
||||
getHookShortDescription,
|
||||
getHookDescription,
|
||||
getTranslatedSourceDisplayMap,
|
||||
createEmptyHookEventInfo,
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
} from './constants.js';
|
||||
|
||||
describe('hooks constants', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getHookExitCodes', () => {
|
||||
it('should return exit codes for Stop event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.Stop);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
expect(exitCodes[0]).toEqual({
|
||||
code: 0,
|
||||
description: expect.any(String),
|
||||
});
|
||||
expect(exitCodes[1]).toEqual({
|
||||
code: 2,
|
||||
description: expect.any(String),
|
||||
});
|
||||
expect(exitCodes[2]).toEqual({
|
||||
code: 'Other',
|
||||
description: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return exit codes for PreToolUse event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.PreToolUse);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
expect(exitCodes[0].code).toBe(0);
|
||||
expect(exitCodes[1].code).toBe(2);
|
||||
expect(exitCodes[2].code).toBe('Other');
|
||||
});
|
||||
|
||||
it('should return exit codes for PostToolUse event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.PostToolUse);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return exit codes for UserPromptSubmit event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.UserPromptSubmit);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return exit codes for Notification event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.Notification);
|
||||
expect(exitCodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return exit codes for SessionStart event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.SessionStart);
|
||||
expect(exitCodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return exit codes for SessionEnd event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.SessionEnd);
|
||||
expect(exitCodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return exit codes for PreCompact event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.PreCompact);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown event', () => {
|
||||
const exitCodes = getHookExitCodes('unknown_event' as HookEventName);
|
||||
expect(exitCodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookShortDescription', () => {
|
||||
it('should return description for PreToolUse', () => {
|
||||
const desc = getHookShortDescription(HookEventName.PreToolUse);
|
||||
expect(desc).toBe('Before tool execution');
|
||||
});
|
||||
|
||||
it('should return description for PostToolUse', () => {
|
||||
const desc = getHookShortDescription(HookEventName.PostToolUse);
|
||||
expect(desc).toBe('After tool execution');
|
||||
});
|
||||
|
||||
it('should return description for UserPromptSubmit', () => {
|
||||
const desc = getHookShortDescription(HookEventName.UserPromptSubmit);
|
||||
expect(desc).toBe('When the user submits a prompt');
|
||||
});
|
||||
|
||||
it('should return description for SessionStart', () => {
|
||||
const desc = getHookShortDescription(HookEventName.SessionStart);
|
||||
expect(desc).toBe('When a new session is started');
|
||||
});
|
||||
|
||||
it('should return empty string for unknown event', () => {
|
||||
const desc = getHookShortDescription('unknown_event' as HookEventName);
|
||||
expect(desc).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookDescription', () => {
|
||||
it('should return description for PreToolUse', () => {
|
||||
const desc = getHookDescription(HookEventName.PreToolUse);
|
||||
expect(desc).toBe('Input to command is JSON of tool call arguments.');
|
||||
});
|
||||
|
||||
it('should return description for PostToolUse', () => {
|
||||
const desc = getHookDescription(HookEventName.PostToolUse);
|
||||
expect(desc).toContain('inputs');
|
||||
expect(desc).toContain('response');
|
||||
});
|
||||
|
||||
it('should return empty string for Stop event', () => {
|
||||
const desc = getHookDescription(HookEventName.Stop);
|
||||
expect(desc).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for unknown event', () => {
|
||||
const desc = getHookDescription('unknown_event' as HookEventName);
|
||||
expect(desc).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTranslatedSourceDisplayMap', () => {
|
||||
it('should return mapping for all sources', () => {
|
||||
const map = getTranslatedSourceDisplayMap();
|
||||
|
||||
expect(map[HooksConfigSource.Project]).toBe('Local Settings');
|
||||
expect(map[HooksConfigSource.User]).toBe('User Settings');
|
||||
expect(map[HooksConfigSource.System]).toBe('System Settings');
|
||||
expect(map[HooksConfigSource.Extensions]).toBe('Extensions');
|
||||
});
|
||||
|
||||
it('should return translated strings', () => {
|
||||
const map = getTranslatedSourceDisplayMap();
|
||||
|
||||
// All values should be strings (translated)
|
||||
Object.values(map).forEach((value) => {
|
||||
expect(typeof value).toBe('string');
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DISPLAY_HOOK_EVENTS', () => {
|
||||
it('should contain all expected hook events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Stop);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreToolUse);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUse);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUseFailure);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Notification);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.UserPromptSubmit);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionStart);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionEnd);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStart);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStop);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreCompact);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PermissionRequest);
|
||||
});
|
||||
|
||||
it('should have 12 events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEmptyHookEventInfo', () => {
|
||||
it('should create empty info for PreToolUse', () => {
|
||||
const info = createEmptyHookEventInfo(HookEventName.PreToolUse);
|
||||
|
||||
expect(info.event).toBe(HookEventName.PreToolUse);
|
||||
expect(info.shortDescription).toBe('Before tool execution');
|
||||
expect(info.description).toBe(
|
||||
'Input to command is JSON of tool call arguments.',
|
||||
);
|
||||
expect(info.exitCodes).toHaveLength(3);
|
||||
expect(info.configs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create empty info for Stop', () => {
|
||||
const info = createEmptyHookEventInfo(HookEventName.Stop);
|
||||
|
||||
expect(info.event).toBe(HookEventName.Stop);
|
||||
expect(info.shortDescription).toBe(
|
||||
'Right before Qwen Code concludes its response',
|
||||
);
|
||||
expect(info.description).toBe('');
|
||||
expect(info.exitCodes).toHaveLength(3);
|
||||
expect(info.configs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create empty info for unknown event', () => {
|
||||
const info = createEmptyHookEventInfo('unknown_event' as HookEventName);
|
||||
|
||||
expect(info.event).toBe('unknown_event');
|
||||
expect(info.shortDescription).toBe('');
|
||||
expect(info.description).toBe('');
|
||||
expect(info.exitCodes).toEqual([]);
|
||||
expect(info.configs).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
217
packages/cli/src/ui/components/hooks/constants.ts
Normal file
217
packages/cli/src/ui/components/hooks/constants.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { HooksConfigSource, HookEventName } from '@qwen-code/qwen-code-core';
|
||||
import type { HookExitCode, HookEventDisplayInfo } from './types.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Exit code descriptions for different hook types
|
||||
*/
|
||||
export function getHookExitCodes(eventName: string): HookExitCode[] {
|
||||
const exitCodesMap: Record<string, HookExitCode[]> = {
|
||||
[HookEventName.Stop]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{
|
||||
code: 2,
|
||||
description: t('show stderr to model and continue conversation'),
|
||||
},
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.PreToolUse]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{ code: 2, description: t('show stderr to model and block tool call') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only but continue with tool call'),
|
||||
},
|
||||
],
|
||||
[HookEventName.PostToolUse]: [
|
||||
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
|
||||
{ code: 2, description: t('show stderr to model immediately') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.PostToolUseFailure]: [
|
||||
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
|
||||
{ code: 2, description: t('show stderr to model immediately') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.Notification]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.UserPromptSubmit]: [
|
||||
{ code: 0, description: t('stdout shown to Qwen') },
|
||||
{
|
||||
code: 2,
|
||||
description: t(
|
||||
'block processing, erase original prompt, and show stderr to user only',
|
||||
),
|
||||
},
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.SessionStart]: [
|
||||
{ code: 0, description: t('stdout shown to Qwen') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only (blocking errors ignored)'),
|
||||
},
|
||||
],
|
||||
[HookEventName.SessionEnd]: [
|
||||
{ code: 0, description: t('command completes successfully') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.SubagentStart]: [
|
||||
{ code: 0, description: t('stdout shown to subagent') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only (blocking errors ignored)'),
|
||||
},
|
||||
],
|
||||
[HookEventName.SubagentStop]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{
|
||||
code: 2,
|
||||
description: t('show stderr to subagent and continue having it run'),
|
||||
},
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.PreCompact]: [
|
||||
{
|
||||
code: 0,
|
||||
description: t('stdout appended as custom compact instructions'),
|
||||
},
|
||||
{ code: 2, description: t('block compaction') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only but continue with compaction'),
|
||||
},
|
||||
],
|
||||
[HookEventName.PermissionRequest]: [
|
||||
{ code: 0, description: t('use hook decision if provided') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
};
|
||||
return exitCodesMap[eventName] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Short one-line description for hooks list view
|
||||
*/
|
||||
export function getHookShortDescription(eventName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
[HookEventName.PreToolUse]: t('Before tool execution'),
|
||||
[HookEventName.PostToolUse]: t('After tool execution'),
|
||||
[HookEventName.PostToolUseFailure]: t('After tool execution fails'),
|
||||
[HookEventName.Notification]: t('When notifications are sent'),
|
||||
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
|
||||
[HookEventName.SessionStart]: t('When a new session is started'),
|
||||
[HookEventName.Stop]: t('Right before Qwen Code concludes its response'),
|
||||
[HookEventName.SubagentStart]: t(
|
||||
'When a subagent (Agent tool call) is started',
|
||||
),
|
||||
[HookEventName.SubagentStop]: t(
|
||||
'Right before a subagent concludes its response',
|
||||
),
|
||||
[HookEventName.PreCompact]: t('Before conversation compaction'),
|
||||
[HookEventName.SessionEnd]: t('When a session is ending'),
|
||||
[HookEventName.PermissionRequest]: t(
|
||||
'When a permission dialog is displayed',
|
||||
),
|
||||
};
|
||||
return descriptions[eventName] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed description for each hook event type (shown in detail view)
|
||||
*/
|
||||
export function getHookDescription(eventName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
[HookEventName.Stop]: '',
|
||||
[HookEventName.PreToolUse]: t(
|
||||
'Input to command is JSON of tool call arguments.',
|
||||
),
|
||||
[HookEventName.PostToolUse]: t(
|
||||
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).',
|
||||
),
|
||||
[HookEventName.PostToolUseFailure]: t(
|
||||
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
|
||||
),
|
||||
[HookEventName.Notification]: t(
|
||||
'Input to command is JSON with notification message and type.',
|
||||
),
|
||||
[HookEventName.UserPromptSubmit]: t(
|
||||
'Input to command is JSON with original user prompt text.',
|
||||
),
|
||||
[HookEventName.SessionStart]: t(
|
||||
'Input to command is JSON with session start source.',
|
||||
),
|
||||
[HookEventName.SessionEnd]: t(
|
||||
'Input to command is JSON with session end reason.',
|
||||
),
|
||||
[HookEventName.SubagentStart]: t(
|
||||
'Input to command is JSON with agent_id and agent_type.',
|
||||
),
|
||||
[HookEventName.SubagentStop]: t(
|
||||
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.',
|
||||
),
|
||||
[HookEventName.PreCompact]: t(
|
||||
'Input to command is JSON with compaction details.',
|
||||
),
|
||||
[HookEventName.PermissionRequest]: t(
|
||||
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.',
|
||||
),
|
||||
};
|
||||
return descriptions[eventName] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Source display mapping (translated)
|
||||
*/
|
||||
export function getTranslatedSourceDisplayMap(): Record<
|
||||
HooksConfigSource,
|
||||
string
|
||||
> {
|
||||
return {
|
||||
[HooksConfigSource.Project]: t('Local Settings'),
|
||||
[HooksConfigSource.User]: t('User Settings'),
|
||||
[HooksConfigSource.System]: t('System Settings'),
|
||||
[HooksConfigSource.Extensions]: t('Extensions'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List of hook events to display in the UI
|
||||
*/
|
||||
export const DISPLAY_HOOK_EVENTS: HookEventName[] = [
|
||||
HookEventName.Stop,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.PostToolUseFailure,
|
||||
HookEventName.Notification,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
HookEventName.SessionEnd,
|
||||
HookEventName.SubagentStart,
|
||||
HookEventName.SubagentStop,
|
||||
HookEventName.PreCompact,
|
||||
HookEventName.PermissionRequest,
|
||||
];
|
||||
|
||||
/**
|
||||
* Create empty hook event display info
|
||||
*/
|
||||
export function createEmptyHookEventInfo(
|
||||
eventName: HookEventName,
|
||||
): HookEventDisplayInfo {
|
||||
return {
|
||||
event: eventName,
|
||||
shortDescription: getHookShortDescription(eventName),
|
||||
description: getHookDescription(eventName),
|
||||
exitCodes: getHookExitCodes(eventName),
|
||||
configs: [],
|
||||
};
|
||||
}
|
||||
11
packages/cli/src/ui/components/hooks/index.ts
Normal file
11
packages/cli/src/ui/components/hooks/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { HooksManagementDialog } from './HooksManagementDialog.js';
|
||||
export { HooksListStep } from './HooksListStep.js';
|
||||
export { HookDetailStep } from './HookDetailStep.js';
|
||||
export * from './types.js';
|
||||
export * from './constants.js';
|
||||
60
packages/cli/src/ui/components/hooks/types.ts
Normal file
60
packages/cli/src/ui/components/hooks/types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
HookConfig,
|
||||
HooksConfigSource,
|
||||
HookEventName,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Exit code description for hooks
|
||||
*/
|
||||
export interface HookExitCode {
|
||||
code: number | string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI display information for a hook event
|
||||
*/
|
||||
export interface HookEventDisplayInfo {
|
||||
event: HookEventName;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
exitCodes: HookExitCode[];
|
||||
configs: HookConfigDisplayInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UI display information for a hook configuration
|
||||
*/
|
||||
export interface HookConfigDisplayInfo {
|
||||
config: HookConfig;
|
||||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
sourcePath?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook management dialog step names
|
||||
*/
|
||||
export const HOOKS_MANAGEMENT_STEPS = {
|
||||
HOOKS_LIST: 'hooks_list',
|
||||
HOOK_DETAIL: 'hook_detail',
|
||||
HOOK_CONFIG_DETAIL: 'hook_config_detail',
|
||||
} as const;
|
||||
|
||||
export type HooksManagementStep =
|
||||
(typeof HOOKS_MANAGEMENT_STEPS)[keyof typeof HOOKS_MANAGEMENT_STEPS];
|
||||
|
||||
/**
|
||||
* Props for HooksManagementDialog
|
||||
*/
|
||||
export interface HooksManagementDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
34
packages/cli/src/ui/components/messages/BtwMessage.test.tsx
Normal file
34
packages/cli/src/ui/components/messages/BtwMessage.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { BtwMessage } from './BtwMessage.js';
|
||||
|
||||
describe('BtwMessage', () => {
|
||||
it('is wrapped in React.memo to avoid unnecessary layout rerenders', () => {
|
||||
expect((BtwMessage as unknown as { $$typeof?: symbol }).$$typeof).toBe(
|
||||
Symbol.for('react.memo'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the side question and answer', () => {
|
||||
const { lastFrame } = render(
|
||||
<BtwMessage
|
||||
btw={{
|
||||
question: 'side question',
|
||||
answer: 'side answer',
|
||||
isPending: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('/btw');
|
||||
expect(output).toContain('side question');
|
||||
expect(output).toContain('side answer');
|
||||
});
|
||||
});
|
||||
54
packages/cli/src/ui/components/messages/BtwMessage.tsx
Normal file
54
packages/cli/src/ui/components/messages/BtwMessage.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { BtwProps } from '../../types.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
export interface BtwDisplayProps {
|
||||
btw: BtwProps;
|
||||
}
|
||||
|
||||
const BtwMessageInternal: React.FC<BtwDisplayProps> = ({ btw }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Text color={Colors.AccentYellow} bold>
|
||||
{'/btw '}
|
||||
</Text>
|
||||
<Text wrap="wrap" color={Colors.AccentYellow}>
|
||||
{btw.question}
|
||||
</Text>
|
||||
</Box>
|
||||
{btw.isPending ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={Colors.AccentYellow}>{'+ '}</Text>
|
||||
<Text color={Colors.AccentYellow}>{t('Answering...')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t('Press Escape to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text wrap="wrap">{btw.answer}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t('Press Space, Enter, or Escape to dismiss')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const BtwMessage = React.memo(BtwMessageInternal);
|
||||
|
|
@ -17,7 +17,11 @@ import type {
|
|||
Config,
|
||||
EditorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
IdeClient,
|
||||
ToolConfirmationOutcome,
|
||||
buildHumanReadableRuleLabel,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
|
|
@ -74,6 +78,12 @@ export const ToolConfirmationMessage: React.FC<
|
|||
}, [config]);
|
||||
|
||||
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
|
||||
// Call onConfirm before resolving the IDE diff so that the CLI outcome
|
||||
// (e.g. ProceedAlways) is processed first. resolveDiffFromCli would
|
||||
// otherwise trigger the scheduler's ideConfirmation .then() handler
|
||||
// with ProceedOnce, racing with the intended CLI outcome.
|
||||
onConfirm(outcome);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (config.getIdeMode() && isDiffingEnabled) {
|
||||
const cliOutcome =
|
||||
|
|
@ -84,7 +94,6 @@ export const ToolConfirmationMessage: React.FC<
|
|||
);
|
||||
}
|
||||
}
|
||||
onConfirm(outcome);
|
||||
};
|
||||
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
|
|
@ -243,16 +252,24 @@ export const ToolConfirmationMessage: React.FC<
|
|||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel = executionProps.permissionRules?.length
|
||||
? ` [${executionProps.permissionRules.join(', ')}]`
|
||||
const friendlyLabel = executionProps.permissionRules?.length
|
||||
? ` ${buildHumanReadableRuleLabel(executionProps.permissionRules)}`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
label: friendlyLabel
|
||||
? t('Always allow {{action}} in this project', {
|
||||
action: friendlyLabel.trim(),
|
||||
})
|
||||
: t('Always allow in this project'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
label: friendlyLabel
|
||||
? t('Always allow {{action}} for this user', {
|
||||
action: friendlyLabel.trim(),
|
||||
})
|
||||
: t('Always allow for this user'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
|
|
@ -324,18 +341,26 @@ export const ToolConfirmationMessage: React.FC<
|
|||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel =
|
||||
const friendlyLabel =
|
||||
'permissionRules' in infoProps &&
|
||||
(infoProps as { permissionRules?: string[] }).permissionRules?.length
|
||||
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
|
||||
? ` ${buildHumanReadableRuleLabel((infoProps as { permissionRules?: string[] }).permissionRules!)}`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
label: friendlyLabel
|
||||
? t('Always allow {{action}} in this project', {
|
||||
action: friendlyLabel.trim(),
|
||||
})
|
||||
: t('Always allow in this project'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
label: friendlyLabel
|
||||
? t('Always allow {{action}} for this user', {
|
||||
action: friendlyLabel.trim(),
|
||||
})
|
||||
: t('Always allow for this user'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
|
|
@ -401,16 +426,24 @@ export const ToolConfirmationMessage: React.FC<
|
|||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel = mcpProps.permissionRules?.length
|
||||
? ` [${mcpProps.permissionRules.join(', ')}]`
|
||||
const friendlyLabel = mcpProps.permissionRules?.length
|
||||
? ` ${buildHumanReadableRuleLabel(mcpProps.permissionRules)}`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
label: friendlyLabel
|
||||
? t('Always allow {{action}} in this project', {
|
||||
action: friendlyLabel.trim(),
|
||||
})
|
||||
: t('Always allow in this project'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
label: friendlyLabel
|
||||
? t('Always allow {{action}} for this user', {
|
||||
action: friendlyLabel.trim(),
|
||||
})
|
||||
: t('Always allow for this user'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -577,7 +577,16 @@ export function KeypressProvider({
|
|||
}
|
||||
};
|
||||
|
||||
// Matches terminal query responses (DA1, DA2, Kitty protocol query)
|
||||
// that may arrive late from startup detection in kittyProtocolDetector.
|
||||
// These are never valid user input.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const TERMINAL_RESPONSE_RE = /^\x1b\[[?>][\d;]*[uc]$/;
|
||||
|
||||
const handleKeypress = async (_: unknown, key: Key) => {
|
||||
if (TERMINAL_RESPONSE_RE.test(key.sequence)) {
|
||||
return;
|
||||
}
|
||||
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '@qwen-code/qwen-code-core';
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
|
||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||
|
|
@ -45,6 +46,11 @@ export interface UIActions {
|
|||
apiKey: string,
|
||||
region?: CodingPlanRegion,
|
||||
) => Promise<void>;
|
||||
handleAlibabaStandardSubmit: (
|
||||
apiKey: string,
|
||||
region: AlibabaStandardRegion,
|
||||
modelIdsInput: string,
|
||||
) => Promise<void>;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
onAuthError: (error: string | null) => void;
|
||||
cancelAuthentication: () => void;
|
||||
|
|
@ -83,6 +89,10 @@ export interface UIActions {
|
|||
closeExtensionsManagerDialog: () => void;
|
||||
// MCP dialog
|
||||
closeMcpDialog: () => void;
|
||||
// Hooks dialog
|
||||
openHooksDialog: () => void;
|
||||
// Hooks dialog
|
||||
closeHooksDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemBtw,
|
||||
ThoughtSummary,
|
||||
ShellConfirmationRequest,
|
||||
ConfirmationRequest,
|
||||
|
|
@ -104,6 +105,9 @@ export interface UIState {
|
|||
staticExtraHeight: number;
|
||||
dialogsVisible: boolean;
|
||||
pendingHistoryItems: HistoryItemWithoutId[];
|
||||
btwItem: HistoryItemBtw | null;
|
||||
setBtwItem: (item: HistoryItemBtw | null) => void;
|
||||
cancelBtw: () => void;
|
||||
nightly: boolean;
|
||||
branchName: string | undefined;
|
||||
sessionStats: SessionStatsState;
|
||||
|
|
@ -132,6 +136,8 @@ export interface UIState {
|
|||
isExtensionsManagerDialogOpen: boolean;
|
||||
// MCP dialog
|
||||
isMcpDialogOpen: boolean;
|
||||
// Hooks dialog
|
||||
isHooksDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
// Per-task token tracking
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useSessionStats } from '../contexts/SessionContext.js';
|
|||
import type {
|
||||
Message,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemBtw,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItem,
|
||||
ConfirmationRequest,
|
||||
|
|
@ -36,6 +37,7 @@ import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
|
|||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import { isBtwCommand } from '../utils/commandUtils.js';
|
||||
import { clearScreen } from '../../utils/stdioHelpers.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import {
|
||||
|
|
@ -63,6 +65,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
|||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
'btw',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
|
|
@ -84,6 +87,7 @@ interface SlashCommandProcessorActions {
|
|||
openAgentsManagerDialog: () => void;
|
||||
openExtensionsManagerDialog: () => void;
|
||||
openMcpDialog: () => void;
|
||||
openHooksDialog: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,10 +143,20 @@ export const useSlashCommandProcessor = (
|
|||
null,
|
||||
);
|
||||
|
||||
const [btwItem, setBtwItem] = useState<HistoryItemBtw | null>(null);
|
||||
const btwAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelBtw = useCallback(() => {
|
||||
btwAbortControllerRef.current?.abort();
|
||||
btwAbortControllerRef.current = null;
|
||||
setBtwItem(null);
|
||||
}, []);
|
||||
|
||||
// AbortController for cancelling async slash commands via ESC
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelSlashCommand = useCallback(() => {
|
||||
cancelBtw();
|
||||
if (!abortControllerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -156,7 +170,7 @@ export const useSlashCommandProcessor = (
|
|||
);
|
||||
setPendingItem(null);
|
||||
setIsProcessing(false);
|
||||
}, [addItem, setIsProcessing]);
|
||||
}, [addItem, setIsProcessing, cancelBtw]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -251,6 +265,10 @@ export const useSlashCommandProcessor = (
|
|||
setDebugMessage: actions.setDebugMessage,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
btwAbortControllerRef,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
reloadCommands,
|
||||
|
|
@ -279,6 +297,9 @@ export const useSlashCommandProcessor = (
|
|||
actions,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
setGeminiMdFileCount,
|
||||
|
|
@ -366,10 +387,12 @@ export const useSlashCommandProcessor = (
|
|||
abortControllerRef.current = abortController;
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
addItemWithRecording(
|
||||
{ type: MessageType.USER, text: trimmed },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
if (!isBtwCommand(trimmed)) {
|
||||
addItemWithRecording(
|
||||
{ type: MessageType.USER, text: trimmed },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
const {
|
||||
|
|
@ -501,6 +524,9 @@ export const useSlashCommandProcessor = (
|
|||
case 'mcp':
|
||||
actions.openMcpDialog();
|
||||
return { type: 'handled' };
|
||||
case 'hooks':
|
||||
actions.openHooksDialog();
|
||||
return { type: 'handled' };
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
|
|
@ -727,6 +753,9 @@ export const useSlashCommandProcessor = (
|
|||
handleSlashCommand,
|
||||
slashCommands: commands,
|
||||
pendingHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
|
|
|
|||
|
|
@ -417,6 +417,95 @@ describe('useCommandCompletion', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Completion mode detection', () => {
|
||||
it('should switch to AT mode when typing @ after a slash command (#2518)', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }],
|
||||
});
|
||||
|
||||
const text = '/qc:create-issue @file';
|
||||
renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest(text),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAtCompletion).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
pattern: 'file',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remain in SLASH mode when no @ is typed after slash command', async () => {
|
||||
setupMocks({
|
||||
slashSuggestions: [{ label: 'help', value: 'help' }],
|
||||
});
|
||||
|
||||
const text = '/help';
|
||||
renderHook(() =>
|
||||
useCommandCompletion(
|
||||
useTextBufferForTest(text),
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSlashCompletion).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
query: '/help',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete a file path when @ appears after a slash command', async () => {
|
||||
setupMocks({
|
||||
atSuggestions: [{ label: 'src/index.ts', value: 'src/index.ts' }],
|
||||
});
|
||||
|
||||
const text = '/review @src/ind';
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest(text);
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleAutocomplete(0);
|
||||
});
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('/review @src/index.ts ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAutocomplete', () => {
|
||||
it('should complete a partial command', async () => {
|
||||
setupMocks({
|
||||
|
|
|
|||
|
|
@ -74,15 +74,9 @@ export function useCommandCompletion(
|
|||
const { completionMode, query, completionStart, completionEnd } =
|
||||
useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return {
|
||||
completionMode: CompletionMode.SLASH,
|
||||
query: currentLine,
|
||||
completionStart: 0,
|
||||
completionEnd: currentLine.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for @ completion first, so that typing @ after a slash command
|
||||
// still triggers file search (see #2518).
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
|
@ -121,6 +115,15 @@ export function useCommandCompletion(
|
|||
}
|
||||
}
|
||||
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return {
|
||||
completionMode: CompletionMode.SLASH,
|
||||
query: currentLine,
|
||||
completionStart: 0,
|
||||
completionEnd: currentLine.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
completionMode: CompletionMode.IDLE,
|
||||
query: null,
|
||||
|
|
|
|||
|
|
@ -834,7 +834,7 @@ describe('useGeminiStream', () => {
|
|||
|
||||
// Wait for the first part of the response
|
||||
await waitFor(() => {
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call cancelOngoingRequest directly
|
||||
|
|
@ -983,7 +983,7 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Cancel the request
|
||||
|
|
@ -2709,6 +2709,109 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
|
||||
describe('Concurrent Execution Prevention', () => {
|
||||
it('should allow /btw slash commands while a main response is in progress', async () => {
|
||||
let resolveFirstCall!: () => void;
|
||||
|
||||
const firstCallPromise = new Promise<void>((resolve) => {
|
||||
resolveFirstCall = resolve;
|
||||
});
|
||||
|
||||
const firstStream = (async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'First call content',
|
||||
};
|
||||
await firstCallPromise;
|
||||
})();
|
||||
|
||||
mockSendMessageStream.mockImplementation(() => firstStream);
|
||||
mockHandleSlashCommand.mockImplementation(async (command) => {
|
||||
if (command === '/btw quick side question') {
|
||||
return { type: 'handled' };
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
let mainRequest!: Promise<void>;
|
||||
await act(async () => {
|
||||
mainRequest = result.current.submitQuery('First query');
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/btw quick side question');
|
||||
});
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/btw quick side question',
|
||||
);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
resolveFirstCall();
|
||||
await mainRequest;
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep the main request cancellable after submitting /btw in parallel', async () => {
|
||||
let resolveFirstCall!: () => void;
|
||||
let mainAbortSignal: AbortSignal | undefined;
|
||||
|
||||
const firstCallPromise = new Promise<void>((resolve) => {
|
||||
resolveFirstCall = resolve;
|
||||
});
|
||||
|
||||
mockSendMessageStream.mockImplementation((_query, signal) => {
|
||||
mainAbortSignal = signal;
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'First call content',
|
||||
};
|
||||
await firstCallPromise;
|
||||
})();
|
||||
});
|
||||
mockHandleSlashCommand.mockImplementation(async (command) => {
|
||||
if (command === '/btw quick side question') {
|
||||
return { type: 'handled' };
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
let mainRequest!: Promise<void>;
|
||||
await act(async () => {
|
||||
mainRequest = result.current.submitQuery('First query');
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(mainAbortSignal).toBeDefined();
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/btw quick side question');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(mainAbortSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
resolveFirstCall();
|
||||
await mainRequest;
|
||||
}
|
||||
});
|
||||
|
||||
it('should prevent concurrent submitQuery calls', async () => {
|
||||
let resolveFirstCall!: () => void;
|
||||
let resolveSecondCall!: () => void;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ import type {
|
|||
SlashCommandProcessorResult,
|
||||
} from '../types.js';
|
||||
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import {
|
||||
isAtCommand,
|
||||
isBtwCommand,
|
||||
isSlashCommand,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
|
|
@ -1094,11 +1098,18 @@ export const useGeminiStream = (
|
|||
submitType: SendMessageType = SendMessageType.UserQuery,
|
||||
prompt_id?: string,
|
||||
) => {
|
||||
const allowConcurrentBtwDuringResponse =
|
||||
submitType === SendMessageType.UserQuery &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
typeof query === 'string' &&
|
||||
isBtwCommand(query);
|
||||
|
||||
// Prevent concurrent executions of submitQuery, but allow continuations
|
||||
// which are part of the same logical flow (tool responses)
|
||||
if (
|
||||
isSubmittingQueryRef.current &&
|
||||
submitType !== SendMessageType.ToolResult
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
!allowConcurrentBtwDuringResponse
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1106,7 +1117,8 @@ export const useGeminiStream = (
|
|||
if (
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation) &&
|
||||
submitType !== SendMessageType.ToolResult
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
!allowConcurrentBtwDuringResponse
|
||||
)
|
||||
return;
|
||||
|
||||
|
|
@ -1116,7 +1128,10 @@ export const useGeminiStream = (
|
|||
const userMessageTimestamp = Date.now();
|
||||
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (submitType !== SendMessageType.ToolResult) {
|
||||
if (
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
!allowConcurrentBtwDuringResponse
|
||||
) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
// Commit any pending retry error to history (without hint) since the
|
||||
// user is starting a new conversation turn.
|
||||
|
|
@ -1130,9 +1145,15 @@ export const useGeminiStream = (
|
|||
}
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const abortSignal = abortControllerRef.current.signal;
|
||||
turnCancelledRef.current = false;
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
|
||||
// Keep the main stream's cancellation state intact while /btw is handled
|
||||
// in parallel. The side-question can use its own local abort signal.
|
||||
if (!allowConcurrentBtwDuringResponse) {
|
||||
abortControllerRef.current = abortController;
|
||||
turnCancelledRef.current = false;
|
||||
}
|
||||
|
||||
if (!prompt_id) {
|
||||
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
||||
|
|
|
|||
31
packages/cli/src/ui/hooks/useHooksDialog.ts
Normal file
31
packages/cli/src/ui/hooks/useHooksDialog.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface UseHooksDialogReturn {
|
||||
isHooksDialogOpen: boolean;
|
||||
openHooksDialog: () => void;
|
||||
closeHooksDialog: () => void;
|
||||
}
|
||||
|
||||
export const useHooksDialog = (): UseHooksDialogReturn => {
|
||||
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
|
||||
|
||||
const openHooksDialog = useCallback(() => {
|
||||
setIsHooksDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeHooksDialog = useCallback(() => {
|
||||
setIsHooksDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isHooksDialogOpen,
|
||||
openHooksDialog,
|
||||
closeHooksDialog,
|
||||
};
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SessionService,
|
||||
type Config,
|
||||
SessionStartSource,
|
||||
type PermissionMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
|
@ -78,6 +79,7 @@ export function useResumeCommand(
|
|||
?.fireSessionStartEvent(
|
||||
SessionStartSource.Resume,
|
||||
config.getModel() ?? '',
|
||||
String(config.getApprovalMode()) as PermissionMode,
|
||||
);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { MainContent } from '../components/MainContent.js';
|
|||
import { DialogManager } from '../components/DialogManager.js';
|
||||
import { Composer } from '../components/Composer.js';
|
||||
import { ExitWarning } from '../components/ExitWarning.js';
|
||||
import { BtwMessage } from '../components/messages/BtwMessage.js';
|
||||
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
|
||||
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
|
||||
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
|
||||
|
|
@ -66,6 +67,10 @@ export const DefaultAppLayout: React.FC = () => {
|
|||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
</Box>
|
||||
) : uiState.btwItem ? (
|
||||
<Box marginX={2} width={terminalWidth - 4}>
|
||||
<BtwMessage btw={uiState.btwItem.btw} />
|
||||
</Box>
|
||||
) : (
|
||||
<Composer />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { DialogManager } from '../components/DialogManager.js';
|
|||
import { Composer } from '../components/Composer.js';
|
||||
import { Footer } from '../components/Footer.js';
|
||||
import { ExitWarning } from '../components/ExitWarning.js';
|
||||
import { BtwMessage } from '../components/messages/BtwMessage.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
export const ScreenReaderAppLayout: React.FC = () => {
|
||||
|
|
@ -24,6 +25,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
|||
<Box flexGrow={1} overflow="hidden">
|
||||
<MainContent />
|
||||
</Box>
|
||||
|
||||
{uiState.dialogsVisible ? (
|
||||
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
|
||||
<DialogManager
|
||||
|
|
@ -31,6 +33,10 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
|||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
</Box>
|
||||
) : uiState.btwItem ? (
|
||||
<Box marginX={2} width={uiState.terminalWidth - 4}>
|
||||
<BtwMessage btw={uiState.btwItem.btw} />
|
||||
</Box>
|
||||
) : (
|
||||
<Composer />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
btwItem: null,
|
||||
setBtwItem: (_item) => {},
|
||||
cancelBtw: () => {},
|
||||
btwAbortControllerRef: { current: null },
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
|
|
|||
|
|
@ -350,6 +350,17 @@ export type HistoryItemInsightProgress = HistoryItemBase & {
|
|||
progress: InsightProgressProps;
|
||||
};
|
||||
|
||||
export interface BtwProps {
|
||||
question: string;
|
||||
answer: string;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export type HistoryItemBtw = HistoryItemBase & {
|
||||
type: 'btw';
|
||||
btw: BtwProps;
|
||||
};
|
||||
|
||||
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
|
||||
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
|
||||
// 'tools' in historyItem.
|
||||
|
|
@ -383,7 +394,8 @@ export type HistoryItemWithoutId =
|
|||
| HistoryItemContextUsage
|
||||
| HistoryItemArenaAgentComplete
|
||||
| HistoryItemArenaSessionComplete
|
||||
| HistoryItemInsightProgress;
|
||||
| HistoryItemInsightProgress
|
||||
| HistoryItemBtw;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
|
|
@ -411,6 +423,7 @@ export enum MessageType {
|
|||
ARENA_AGENT_COMPLETE = 'arena_agent_complete',
|
||||
ARENA_SESSION_COMPLETE = 'arena_session_complete',
|
||||
INSIGHT_PROGRESS = 'insight_progress',
|
||||
BTW = 'btw',
|
||||
}
|
||||
|
||||
export interface InsightProgressProps {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,17 @@ export const isSlashCommand = (query: string): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const BTW_COMMAND_RE = /^[/?]btw(?:\s|$)/;
|
||||
|
||||
/**
|
||||
* Checks if a query is a /btw side-question invocation.
|
||||
* Accepts both "/btw" and "?btw" prefixes.
|
||||
*/
|
||||
export const isBtwCommand = (query: string): boolean => {
|
||||
const trimmed = query.trim();
|
||||
return trimmed.length > 0 && BTW_COMMAND_RE.test(trimmed);
|
||||
};
|
||||
|
||||
const debugLogger = createDebugLogger('COMMAND_UTILS');
|
||||
|
||||
// Copies a string snippet to the clipboard for different platforms
|
||||
|
|
|
|||
|
|
@ -37,11 +37,20 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
|
|||
const onTimeout = () => {
|
||||
timeoutId = undefined;
|
||||
process.stdin.removeListener('data', handleData);
|
||||
if (!originalRawMode) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
detectionComplete = true;
|
||||
resolve(false);
|
||||
|
||||
// Keep a drain handler briefly to consume any late-arriving terminal
|
||||
// responses that would otherwise leak into the application input.
|
||||
const drainHandler = () => {};
|
||||
process.stdin.on('data', drainHandler);
|
||||
|
||||
setTimeout(() => {
|
||||
process.stdin.removeListener('data', drainHandler);
|
||||
if (!originalRawMode) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
detectionComplete = true;
|
||||
resolve(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue