mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-startup-and-exit-hangs
This commit is contained in:
@@ -1002,6 +1002,7 @@ export async function loadCliConfig(
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
gitCoAuthor: settings.general?.gitCoAuthor,
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
|
||||
@@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
|
||||
}
|
||||
if (extensionConfig.contextFileName) {
|
||||
output.push(
|
||||
`This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`,
|
||||
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
||||
);
|
||||
}
|
||||
if (extensionConfig.excludeTools) {
|
||||
|
||||
@@ -147,6 +147,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable update notification prompts.',
|
||||
showInDialog: false,
|
||||
},
|
||||
gitCoAuthor: {
|
||||
type: 'boolean',
|
||||
label: 'Git Co-Author',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.',
|
||||
showInDialog: false,
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
label: 'Checkpointing',
|
||||
@@ -284,7 +294,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Show Gemini CLI status and thoughts in the terminal window title',
|
||||
'Show Qwen Code status and thoughts in the terminal window title',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideTips: {
|
||||
@@ -312,7 +322,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
|
||||
'Hide the context summary (QWEN.md, MCP servers) above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
footer: {
|
||||
@@ -518,7 +528,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The Gemini model to use for conversations.',
|
||||
description: 'The model to use for conversations.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxSessionTurns: {
|
||||
@@ -649,6 +659,22 @@ const SETTINGS_SCHEMA = {
|
||||
childKey: 'disableCacheControl',
|
||||
showInDialog: true,
|
||||
},
|
||||
schemaCompliance: {
|
||||
type: 'enum',
|
||||
label: 'Tool Schema Compliance',
|
||||
category: 'Generation Configuration',
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'schemaCompliance',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto (Default)' },
|
||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Set no relaunch in tests since process spawning causing issues in tests
|
||||
originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(process.stdin as any).setRawMode) {
|
||||
@@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvNoRelaunch !== undefined) {
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
|
||||
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
|
||||
} else {
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
|
||||
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
@@ -92,7 +92,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
||||
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,7 @@ export default {
|
||||
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
||||
'Folder Trust': 'Folder Trust',
|
||||
'Vision Model Preview': 'Vision Model Preview',
|
||||
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': 'Auto (detect from system)',
|
||||
Text: 'Text',
|
||||
@@ -635,8 +636,8 @@ export default {
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
|
||||
"Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}",
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
|
||||
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}',
|
||||
'Successfully added directories:\n- {{directories}}':
|
||||
'Successfully added directories:\n- {{directories}}',
|
||||
|
||||
@@ -300,6 +300,7 @@ export default {
|
||||
'Tool Output Truncation Lines': '工具输出截断行数',
|
||||
'Folder Trust': '文件夹信任',
|
||||
'Vision Model Preview': '视觉模型预览',
|
||||
'Tool Schema Compliance': '工具 Schema 兼容性',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': '自动(从系统检测)',
|
||||
Text: '文本',
|
||||
@@ -601,8 +602,8 @@ export default {
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
|
||||
'/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。',
|
||||
"Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}",
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
|
||||
'如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
|
||||
'如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}',
|
||||
'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}',
|
||||
'Successfully added directories:\n- {{directories}}':
|
||||
'成功添加目录:\n- {{directories}}',
|
||||
|
||||
@@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
themeCommand,
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
@@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { stdout } = useStdout();
|
||||
|
||||
// Additional hooks moved from App.tsx
|
||||
const { stats: sessionStats } = useSessionStats();
|
||||
const { stats: sessionStats, startNewSession } = useSessionStats();
|
||||
const logger = useLogger(config.storage, sessionStats.sessionId);
|
||||
const branchName = useGitBranchName(config.getTargetDir());
|
||||
|
||||
@@ -435,6 +436,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
const {
|
||||
isResumeDialogOpen,
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
} = useResumeCommand({
|
||||
config,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
remount: refreshStatic,
|
||||
});
|
||||
|
||||
const {
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
@@ -488,6 +501,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
openAuthDialog,
|
||||
@@ -502,6 +516,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1194,7 +1209,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!!proQuotaRequest ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isApprovalModeDialogOpen;
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1222,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1312,6 +1329,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1453,6 +1475,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = {
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
|
||||
{
|
||||
directories: added.join('\n- '),
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('restoreCommand', () => {
|
||||
).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine the .gemini directory path.',
|
||||
content: 'Could not determine the .qwen directory path.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ async function restoreAction(
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine the .gemini directory path.',
|
||||
content: 'Could not determine the .qwen directory path.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
38
packages/cli/src/ui/commands/resumeCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/resumeCommand.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { resumeCommand } from './resumeCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('resumeCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the resume dialog', async () => {
|
||||
// Ensure the command has an action to test.
|
||||
if (!resumeCommand.action) {
|
||||
throw new Error('The resume command must have an action.');
|
||||
}
|
||||
|
||||
const result = await resumeCommand.action(mockContext, '');
|
||||
|
||||
// Assert that the action returns the correct object to trigger the resume dialog.
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'resume',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(resumeCommand.name).toBe('resume');
|
||||
expect(resumeCommand.description).toBe('Resume a previous session');
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
21
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const resumeCommand: SlashCommand = {
|
||||
name: 'resume',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
get description() {
|
||||
return t('Resume a previous session');
|
||||
},
|
||||
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'resume',
|
||||
}),
|
||||
};
|
||||
@@ -124,7 +124,8 @@ export interface OpenDialogActionReturn {
|
||||
| 'subagent_create'
|
||||
| 'subagent_list'
|
||||
| 'permissions'
|
||||
| 'approval-mode';
|
||||
| 'approval-mode'
|
||||
| 'resume';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -290,5 +291,16 @@ export const DialogManager = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<SessionPicker
|
||||
sessionService={config.getSessionService()}
|
||||
currentBranch={uiState.branchName}
|
||||
onSelect={uiActions.handleResume}
|
||||
onCancel={uiActions.closeResumeDialog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({
|
||||
{needsRestart && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
To apply the trust changes, Gemini CLI must be restarted. Press
|
||||
To apply the trust changes, Qwen Code must be restarted. Press
|
||||
'r' to restart CLI now.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { render, Box, Text, useInput, useApp } from 'ink';
|
||||
import {
|
||||
SessionService,
|
||||
type SessionListItem,
|
||||
type ListSessionsResult,
|
||||
getGitBranch,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatRelativeTime } from '../utils/formatters.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface SessionPickerProps {
|
||||
sessionService: SessionService;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text to fit within a given width, adding ellipsis if needed.
|
||||
*/
|
||||
function truncateText(text: string, maxWidth: number): string {
|
||||
if (text.length <= maxWidth) return text;
|
||||
if (maxWidth <= 3) return text.slice(0, maxWidth);
|
||||
return text.slice(0, maxWidth - 3) + '...';
|
||||
}
|
||||
|
||||
function SessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: SessionPickerProps): React.JSX.Element {
|
||||
const { exit } = useApp();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<{
|
||||
sessions: SessionListItem[];
|
||||
hasMore: boolean;
|
||||
nextCursor?: number;
|
||||
}>({
|
||||
sessions: [],
|
||||
hasMore: true,
|
||||
nextCursor: undefined,
|
||||
});
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [terminalSize, setTerminalSize] = useState({
|
||||
width: process.stdout.columns || 80,
|
||||
height: process.stdout.rows || 24,
|
||||
});
|
||||
|
||||
// Update terminal size on resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setTerminalSize({
|
||||
width: process.stdout.columns || 80,
|
||||
height: process.stdout.rows || 24,
|
||||
});
|
||||
};
|
||||
process.stdout.on('resize', handleResize);
|
||||
return () => {
|
||||
process.stdout.off('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filter sessions by current branch if filter is enabled
|
||||
const filteredSessions =
|
||||
filterByBranch && currentBranch
|
||||
? sessionState.sessions.filter(
|
||||
(session) => session.gitBranch === currentBranch,
|
||||
)
|
||||
: sessionState.sessions;
|
||||
|
||||
const hasSentinel = sessionState.hasMore;
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filterByBranch]);
|
||||
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
|
||||
isLoadingMoreRef.current = true;
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: PAGE_SIZE,
|
||||
cursor: sessionState.nextCursor,
|
||||
});
|
||||
|
||||
setSessionState((prev) => ({
|
||||
sessions: [...prev.sessions, ...result.items],
|
||||
hasMore: result.hasMore && result.nextCursor !== undefined,
|
||||
nextCursor: result.nextCursor,
|
||||
}));
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
|
||||
|
||||
// Calculate visible items
|
||||
// Reserved space: header (1), footer (1), separators (2), borders (2)
|
||||
const reservedLines = 6;
|
||||
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
|
||||
// On average, this is ~3 lines per item, but the last item has no margin
|
||||
const itemHeight = 3;
|
||||
const maxVisibleItems = Math.max(
|
||||
1,
|
||||
Math.floor((terminalSize.height - reservedLines) / itemHeight),
|
||||
);
|
||||
|
||||
// Calculate scroll offset
|
||||
const scrollOffset = (() => {
|
||||
if (filteredSessions.length <= maxVisibleItems) return 0;
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
})();
|
||||
|
||||
const visibleSessions = filteredSessions.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxVisibleItems,
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Sentinel (invisible) sits after the last session item; consider it visible
|
||||
// once the viewport reaches the final real item.
|
||||
const sentinelVisible =
|
||||
hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
|
||||
// Load more when sentinel enters view or when filtered list is empty.
|
||||
useEffect(() => {
|
||||
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
|
||||
|
||||
const shouldLoadMore =
|
||||
filteredSessions.length === 0 ||
|
||||
sentinelVisible ||
|
||||
isLoadingMoreRef.current;
|
||||
|
||||
if (shouldLoadMore) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
filteredSessions.length,
|
||||
loadMoreSessions,
|
||||
sessionState.hasMore,
|
||||
sentinelVisible,
|
||||
]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Ignore input if already exiting
|
||||
if (isExiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (key.escape || (key.ctrl && input === 'c')) {
|
||||
setIsExiting(true);
|
||||
onCancel();
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
setIsExiting(true);
|
||||
onSelect(session.sessionId);
|
||||
exit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow || input === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((prev) =>
|
||||
Math.min(filteredSessions.length - 1, prev + 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'b' || input === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Filtered sessions may have changed, ensure selectedIndex is valid
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex >= filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
) {
|
||||
setSelectedIndex(filteredSessions.length - 1);
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Calculate content width (terminal width minus border padding)
|
||||
const contentWidth = terminalSize.width - 4;
|
||||
const promptMaxWidth = contentWidth - 4; // Account for "› " prefix
|
||||
|
||||
// Return empty while exiting to prevent visual glitches
|
||||
if (isExiting) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={terminalSize.width}
|
||||
height={terminalSize.height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Main container with single border */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={terminalSize.width}
|
||||
height={terminalSize.height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header row */}
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Resume Session
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Separator line */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>
|
||||
{'─'.repeat(terminalSize.width - 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list with auto-scrolling */}
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<Box paddingY={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{filterByBranch
|
||||
? `No sessions found for branch "${currentBranch}"`
|
||||
: 'No sessions found'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
visibleSessions.map((session, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const isFirst = visibleIndex === 0;
|
||||
const isLast = visibleIndex === visibleSessions.length - 1;
|
||||
const timeAgo = formatRelativeTime(session.mtime);
|
||||
const messageText =
|
||||
session.messageCount === 1
|
||||
? '1 message'
|
||||
: `${session.messageCount} messages`;
|
||||
|
||||
// Show scroll indicator on first/last visible items
|
||||
const showUpIndicator = isFirst && showScrollUp;
|
||||
const showDownIndicator = isLast && showScrollDown;
|
||||
|
||||
// Determine the prefix: selector takes priority over scroll indicator
|
||||
const prefix = isSelected
|
||||
? '› '
|
||||
: showUpIndicator
|
||||
? '↑ '
|
||||
: showDownIndicator
|
||||
? '↓ '
|
||||
: ' ';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={session.sessionId}
|
||||
flexDirection="column"
|
||||
marginBottom={isLast ? 0 : 1}
|
||||
>
|
||||
{/* First line: prefix (selector or scroll indicator) + prompt text */}
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isSelected
|
||||
? theme.text.accent
|
||||
: showUpIndicator || showDownIndicator
|
||||
? theme.text.secondary
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{truncateText(
|
||||
session.prompt || '(empty prompt)',
|
||||
promptMaxWidth,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Second line: metadata (aligned with prompt text) */}
|
||||
<Box>
|
||||
<Text>{' '}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{timeAgo} · {messageText}
|
||||
{session.gitBranch && ` · ${session.gitBranch}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Separator line */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>
|
||||
{'─'.repeat(terminalSize.width - 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer with keyboard shortcuts */}
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentBranch && (
|
||||
<>
|
||||
<Text
|
||||
bold={filterByBranch}
|
||||
color={filterByBranch ? theme.text.accent : undefined}
|
||||
>
|
||||
B
|
||||
</Text>
|
||||
{' to toggle branch · '}
|
||||
</>
|
||||
)}
|
||||
{'↑↓ to navigate · Esc to cancel'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen.
|
||||
*/
|
||||
function clearScreen(): void {
|
||||
// Move cursor to home position and clear screen
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an interactive session picker and returns the selected session ID.
|
||||
* Returns undefined if the user cancels or no sessions are available.
|
||||
*/
|
||||
export async function showResumeSessionPicker(
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string | undefined> {
|
||||
const sessionService = new SessionService(cwd);
|
||||
const hasSession = await sessionService.loadLastSession();
|
||||
if (!hasSession) {
|
||||
console.log('No sessions found. Start a new session with `qwen`.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentBranch = getGitBranch(cwd);
|
||||
|
||||
// Clear the screen before showing the picker for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Enable raw mode for keyboard input if not already enabled
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY && !wasRaw) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<SessionPicker
|
||||
sessionService={sessionService}
|
||||
currentBranch={currentBranch}
|
||||
onSelect={(id) => {
|
||||
selectedId = id;
|
||||
}}
|
||||
onCancel={() => {
|
||||
selectedId = undefined;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
);
|
||||
|
||||
waitUntilExit().then(() => {
|
||||
unmount();
|
||||
|
||||
// Clear the screen after the picker closes for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Restore raw mode state only if we changed it and user cancelled
|
||||
// (if user selected a session, main app will handle raw mode)
|
||||
if (process.stdin.isTTY && !wasRaw && !selectedId) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
|
||||
resolve(selectedId);
|
||||
});
|
||||
});
|
||||
}
|
||||
251
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
251
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
SessionListItem as SessionData,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useSessionPicker } from '../hooks/useSessionPicker.js';
|
||||
import { formatRelativeTime } from '../utils/formatters.js';
|
||||
import {
|
||||
formatMessageCount,
|
||||
truncateText,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export interface SessionPickerProps {
|
||||
sessionService: SessionService | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
currentBranch?: string;
|
||||
|
||||
/**
|
||||
* Scroll mode. When true, keep selection centered (fullscreen-style).
|
||||
* Defaults to true so dialog + standalone behave identically.
|
||||
*/
|
||||
centerSelection?: boolean;
|
||||
}
|
||||
|
||||
const PREFIX_CHARS = {
|
||||
selected: '› ',
|
||||
scrollUp: '↑ ',
|
||||
scrollDown: '↓ ',
|
||||
normal: ' ',
|
||||
};
|
||||
|
||||
interface SessionListItemViewProps {
|
||||
session: SessionData;
|
||||
isSelected: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
maxPromptWidth: number;
|
||||
prefixChars?: {
|
||||
selected: string;
|
||||
scrollUp: string;
|
||||
scrollDown: string;
|
||||
normal: string;
|
||||
};
|
||||
boldSelectedPrefix?: boolean;
|
||||
}
|
||||
|
||||
function SessionListItemView({
|
||||
session,
|
||||
isSelected,
|
||||
isFirst,
|
||||
isLast,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
maxPromptWidth,
|
||||
prefixChars = PREFIX_CHARS,
|
||||
boldSelectedPrefix = true,
|
||||
}: SessionListItemViewProps): React.JSX.Element {
|
||||
const timeAgo = formatRelativeTime(session.mtime);
|
||||
const messageText = formatMessageCount(session.messageCount);
|
||||
|
||||
const showUpIndicator = isFirst && showScrollUp;
|
||||
const showDownIndicator = isLast && showScrollDown;
|
||||
|
||||
const prefix = isSelected
|
||||
? prefixChars.selected
|
||||
: showUpIndicator
|
||||
? prefixChars.scrollUp
|
||||
: showDownIndicator
|
||||
? prefixChars.scrollDown
|
||||
: prefixChars.normal;
|
||||
|
||||
const promptText = session.prompt || '(empty prompt)';
|
||||
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isSelected
|
||||
? theme.text.accent
|
||||
: showUpIndicator || showDownIndicator
|
||||
? theme.text.secondary
|
||||
: undefined
|
||||
}
|
||||
bold={isSelected && boldSelectedPrefix}
|
||||
>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
>
|
||||
{truncatedPrompt}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{timeAgo} · {messageText}
|
||||
{session.gitBranch && ` · ${session.gitBranch}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionPicker(props: SessionPickerProps) {
|
||||
const {
|
||||
sessionService,
|
||||
onSelect,
|
||||
onCancel,
|
||||
currentBranch,
|
||||
centerSelection = true,
|
||||
} = props;
|
||||
|
||||
const { columns: width, rows: height } = useTerminalSize();
|
||||
|
||||
// Calculate box width (width + 6 for border padding)
|
||||
const boxWidth = width + 6;
|
||||
// Calculate visible items (same heuristic as before)
|
||||
// Reserved space: header (1), footer (1), separators (2), borders (2)
|
||||
const reservedLines = 6;
|
||||
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
|
||||
const itemHeight = 3;
|
||||
const maxVisibleItems = Math.max(
|
||||
1,
|
||||
Math.floor((height - reservedLines) / itemHeight),
|
||||
);
|
||||
|
||||
const picker = useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={boxWidth}
|
||||
height={height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={boxWidth}
|
||||
height={height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header row */}
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Resume Session')}
|
||||
</Text>
|
||||
{picker.filterByBranch && currentBranch && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{t('(branch: {{branch}})', { branch: currentBranch })}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list */}
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
||||
{!sessionService || picker.isLoading ? (
|
||||
<Box paddingY={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Loading sessions...')}
|
||||
</Text>
|
||||
</Box>
|
||||
) : picker.filteredSessions.length === 0 ? (
|
||||
<Box paddingY={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{picker.filterByBranch
|
||||
? t('No sessions found for branch "{{branch}}"', {
|
||||
branch: currentBranch ?? '',
|
||||
})
|
||||
: t('No sessions found')}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
picker.visibleSessions.map((session, visibleIndex) => {
|
||||
const actualIndex = picker.scrollOffset + visibleIndex;
|
||||
return (
|
||||
<SessionListItemView
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
isSelected={actualIndex === picker.selectedIndex}
|
||||
isFirst={visibleIndex === 0}
|
||||
isLast={visibleIndex === picker.visibleSessions.length - 1}
|
||||
showScrollUp={picker.showScrollUp}
|
||||
showScrollDown={picker.showScrollDown}
|
||||
maxPromptWidth={width}
|
||||
prefixChars={PREFIX_CHARS}
|
||||
boldSelectedPrefix={false}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box paddingX={1}>
|
||||
<Box flexDirection="row">
|
||||
{currentBranch && (
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text
|
||||
bold={picker.filterByBranch}
|
||||
color={picker.filterByBranch ? theme.text.accent : undefined}
|
||||
>
|
||||
B
|
||||
</Text>
|
||||
{t(' to toggle branch')} ·
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('↑↓ to navigate · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
624
packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
Normal file
624
packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
import type {
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
getGitBranch: vi.fn().mockReturnValue('main'),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock terminal size
|
||||
const mockTerminalSize = { columns: 80, rows: 24 };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process.stdout, 'columns', {
|
||||
value: mockTerminalSize.columns,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'rows', {
|
||||
value: mockTerminalSize.rows,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create mock sessions
|
||||
function createMockSession(
|
||||
overrides: Partial<SessionListItem> = {},
|
||||
): SessionListItem {
|
||||
return {
|
||||
sessionId: 'test-session-id',
|
||||
cwd: '/test/path',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
mtime: Date.now(),
|
||||
prompt: 'Test prompt',
|
||||
gitBranch: 'main',
|
||||
filePath: '/test/path/sessions/test-session-id.jsonl',
|
||||
messageCount: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock session service
|
||||
function createMockSessionService(
|
||||
sessions: SessionListItem[] = [],
|
||||
hasMore = false,
|
||||
) {
|
||||
return {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: sessions,
|
||||
hasMore,
|
||||
nextCursor: hasMore ? Date.now() : undefined,
|
||||
} as ListSessionsResult),
|
||||
loadSession: vi.fn(),
|
||||
loadLastSession: vi
|
||||
.fn()
|
||||
.mockResolvedValue(sessions.length > 0 ? {} : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionPicker', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Empty Sessions', () => {
|
||||
it('should show sessions with 0 messages', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'empty-1',
|
||||
messageCount: 0,
|
||||
prompt: '',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'with-messages',
|
||||
messageCount: 5,
|
||||
prompt: 'Hello',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'empty-2',
|
||||
messageCount: 0,
|
||||
prompt: '(empty prompt)',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Hello');
|
||||
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
|
||||
expect(output).toContain('0 messages');
|
||||
});
|
||||
|
||||
it('should show sessions even when all sessions are empty', async () => {
|
||||
const sessions = [
|
||||
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
|
||||
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0 messages');
|
||||
});
|
||||
|
||||
it('should show sessions with 1 or more messages', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'one-msg',
|
||||
messageCount: 1,
|
||||
prompt: 'Single message',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'many-msg',
|
||||
messageCount: 10,
|
||||
prompt: 'Many messages',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Single message');
|
||||
expect(output).toContain('Many messages');
|
||||
expect(output).toContain('1 message');
|
||||
expect(output).toContain('10 messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Branch Filtering', () => {
|
||||
it('should filter by branch when B is pressed', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
gitBranch: 'main',
|
||||
prompt: 'Main branch',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
gitBranch: 'feature',
|
||||
prompt: 'Feature branch',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's3',
|
||||
gitBranch: 'main',
|
||||
prompt: 'Also main',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// All sessions should be visible initially
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('Main branch');
|
||||
expect(output).toContain('Feature branch');
|
||||
|
||||
// Press B to filter by branch
|
||||
stdin.write('B');
|
||||
await wait(50);
|
||||
|
||||
output = lastFrame();
|
||||
// Only main branch sessions should be visible
|
||||
expect(output).toContain('Main branch');
|
||||
expect(output).toContain('Also main');
|
||||
expect(output).not.toContain('Feature branch');
|
||||
});
|
||||
|
||||
it('should combine empty session filter with branch filter', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
gitBranch: 'main',
|
||||
messageCount: 0,
|
||||
prompt: 'Empty main',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
gitBranch: 'main',
|
||||
messageCount: 5,
|
||||
prompt: 'Valid main',
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's3',
|
||||
gitBranch: 'feature',
|
||||
messageCount: 5,
|
||||
prompt: 'Valid feature',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Press B to filter by branch
|
||||
stdin.write('B');
|
||||
await wait(50);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should only show sessions from main branch (including 0-message sessions)
|
||||
expect(output).toContain('Valid main');
|
||||
expect(output).toContain('Empty main');
|
||||
expect(output).not.toContain('Valid feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should navigate with arrow keys', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
prompt: 'First session',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
prompt: 'Second session',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's3',
|
||||
prompt: 'Third session',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// First session should be selected initially (indicated by >)
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('First session');
|
||||
|
||||
// Navigate down
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait(50);
|
||||
|
||||
output = lastFrame();
|
||||
// Selection indicator should move
|
||||
expect(output).toBeDefined();
|
||||
});
|
||||
|
||||
it('should navigate with vim keys (j/k)', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
prompt: 'First',
|
||||
messageCount: 1,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 's2',
|
||||
prompt: 'Second',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Navigate with j (down)
|
||||
stdin.write('j');
|
||||
await wait(50);
|
||||
|
||||
// Navigate with k (up)
|
||||
stdin.write('k');
|
||||
await wait(50);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should select session on Enter', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'selected-session',
|
||||
prompt: 'Select me',
|
||||
messageCount: 1,
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Press Enter to select
|
||||
stdin.write('\r');
|
||||
await wait(50);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('selected-session');
|
||||
});
|
||||
|
||||
it('should cancel on Escape', async () => {
|
||||
const sessions = [
|
||||
createMockSession({ sessionId: 's1', messageCount: 1 }),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
// Press Escape to cancel
|
||||
stdin.write('\u001B');
|
||||
await wait(50);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display', () => {
|
||||
it('should show session metadata', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 's1',
|
||||
prompt: 'Test prompt text',
|
||||
messageCount: 5,
|
||||
gitBranch: 'feature-branch',
|
||||
}),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Test prompt text');
|
||||
expect(output).toContain('5 messages');
|
||||
expect(output).toContain('feature-branch');
|
||||
});
|
||||
|
||||
it('should show header and footer', async () => {
|
||||
const sessions = [createMockSession({ messageCount: 1 })];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Resume Session');
|
||||
expect(output).toContain('↑↓ to navigate');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should show branch toggle hint when currentBranch is provided', async () => {
|
||||
const sessions = [createMockSession({ messageCount: 1 })];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('B');
|
||||
expect(output).toContain('toggle branch');
|
||||
});
|
||||
|
||||
it('should truncate long prompts', async () => {
|
||||
const longPrompt = 'A'.repeat(300);
|
||||
const sessions = [
|
||||
createMockSession({ prompt: longPrompt, messageCount: 1 }),
|
||||
];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should contain ellipsis for truncated text
|
||||
expect(output).toContain('...');
|
||||
// Should NOT contain the full untruncated prompt (300 A's in a row)
|
||||
expect(output).not.toContain(longPrompt);
|
||||
});
|
||||
|
||||
it('should show "(empty prompt)" for sessions without prompt text', async () => {
|
||||
const sessions = [createMockSession({ prompt: '', messageCount: 1 })];
|
||||
const mockService = createMockSessionService(sessions);
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('(empty prompt)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('should load more sessions when scrolling to bottom', async () => {
|
||||
const firstPage = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockSession({
|
||||
sessionId: `session-${i}`,
|
||||
prompt: `Session ${i}`,
|
||||
messageCount: 1,
|
||||
mtime: Date.now() - i * 1000,
|
||||
}),
|
||||
);
|
||||
const secondPage = Array.from({ length: 3 }, (_, i) =>
|
||||
createMockSession({
|
||||
sessionId: `session-${i + 5}`,
|
||||
prompt: `Session ${i + 5}`,
|
||||
messageCount: 1,
|
||||
mtime: Date.now() - (i + 5) * 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockService = {
|
||||
listSessions: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: firstPage,
|
||||
hasMore: true,
|
||||
nextCursor: Date.now() - 5000,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: secondPage,
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
}),
|
||||
loadSession: vi.fn(),
|
||||
loadLastSession: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(200);
|
||||
|
||||
// First page should be loaded
|
||||
expect(mockService.listSessions).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/cli/src/ui/components/StandaloneSessionPicker.tsx
Normal file
122
packages/cli/src/ui/components/StandaloneSessionPicker.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { render, Box, useApp } from 'ink';
|
||||
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface StandalonePickerScreenProps {
|
||||
sessionService: SessionService;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
currentBranch?: string;
|
||||
}
|
||||
|
||||
function StandalonePickerScreen({
|
||||
sessionService,
|
||||
onSelect,
|
||||
onCancel,
|
||||
currentBranch,
|
||||
}: StandalonePickerScreenProps): React.JSX.Element {
|
||||
const { exit } = useApp();
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const handleExit = () => {
|
||||
setIsExiting(true);
|
||||
exit();
|
||||
};
|
||||
|
||||
// Return empty while exiting to prevent visual glitches
|
||||
if (isExiting) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPicker
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
onSelect(id);
|
||||
handleExit();
|
||||
}}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
handleExit();
|
||||
}}
|
||||
currentBranch={currentBranch}
|
||||
centerSelection={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen.
|
||||
*/
|
||||
function clearScreen(): void {
|
||||
// Move cursor to home position and clear screen
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an interactive session picker and returns the selected session ID.
|
||||
* Returns undefined if the user cancels or no sessions are available.
|
||||
*/
|
||||
export async function showResumeSessionPicker(
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string | undefined> {
|
||||
const sessionService = new SessionService(cwd);
|
||||
const hasSession = await sessionService.loadLastSession();
|
||||
if (!hasSession) {
|
||||
console.log('No sessions found. Start a new session with `qwen`.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Clear the screen before showing the picker for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Enable raw mode for keyboard input if not already enabled
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY && !wasRaw) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<StandalonePickerScreen
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
selectedId = id;
|
||||
}}
|
||||
onCancel={() => {
|
||||
selectedId = undefined;
|
||||
}}
|
||||
currentBranch={getGitBranch(cwd)}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
);
|
||||
|
||||
waitUntilExit().then(() => {
|
||||
unmount();
|
||||
|
||||
// Clear the screen after the picker closes for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
// Restore raw mode state only if we changed it and user cancelled
|
||||
// (if user selected a session, main app will handle raw mode)
|
||||
if (process.stdin.isTTY && !wasRaw && !selectedId) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
|
||||
resolve(selectedId);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -64,6 +64,10 @@ export interface UIActions {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog: () => void;
|
||||
closeAgentsManagerDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface UIState {
|
||||
isModelDialogOpen: boolean;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
isResumeDialogOpen: boolean;
|
||||
slashCommands: readonly SlashCommand[];
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
|
||||
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'clear',
|
||||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
@@ -66,6 +67,7 @@ interface SlashCommandProcessorActions {
|
||||
openModelDialog: () => void;
|
||||
openPermissionsDialog: () => void;
|
||||
openApprovalModeDialog: () => void;
|
||||
openResumeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
@@ -417,6 +419,9 @@ export const useSlashCommandProcessor = (
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'resume':
|
||||
actions.openResumeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
default: {
|
||||
|
||||
190
packages/cli/src/ui/hooks/useResumeCommand.test.ts
Normal file
190
packages/cli/src/ui/hooks/useResumeCommand.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useResumeCommand } from './useResumeCommand.js';
|
||||
|
||||
const resumeMocks = vi.hoisted(() => {
|
||||
let resolveLoadSession:
|
||||
| ((value: { conversation: unknown } | undefined) => void)
|
||||
| undefined;
|
||||
let pendingLoadSession:
|
||||
| Promise<{ conversation: unknown } | undefined>
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
createPendingLoadSession() {
|
||||
pendingLoadSession = new Promise((resolve) => {
|
||||
resolveLoadSession = resolve;
|
||||
});
|
||||
return pendingLoadSession;
|
||||
},
|
||||
resolvePendingLoadSession(value: { conversation: unknown } | undefined) {
|
||||
resolveLoadSession?.(value);
|
||||
},
|
||||
getPendingLoadSession() {
|
||||
return pendingLoadSession;
|
||||
},
|
||||
reset() {
|
||||
resolveLoadSession = undefined;
|
||||
pendingLoadSession = undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/resumeHistoryUtils.js', () => ({
|
||||
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => {
|
||||
class SessionService {
|
||||
constructor(_cwd: string) {}
|
||||
async loadSession(_sessionId: string) {
|
||||
return (
|
||||
resumeMocks.getPendingLoadSession() ??
|
||||
Promise.resolve({
|
||||
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SessionService,
|
||||
};
|
||||
});
|
||||
|
||||
describe('useResumeCommand', () => {
|
||||
it('should initialize with dialog closed', () => {
|
||||
const { result } = renderHook(() => useResumeCommand());
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should open the dialog when openResumeDialog is called', () => {
|
||||
const { result } = renderHook(() => useResumeCommand());
|
||||
|
||||
act(() => {
|
||||
result.current.openResumeDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the dialog when closeResumeDialog is called', () => {
|
||||
const { result } = renderHook(() => useResumeCommand());
|
||||
|
||||
// Open the dialog first
|
||||
act(() => {
|
||||
result.current.openResumeDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(true);
|
||||
|
||||
// Close the dialog
|
||||
act(() => {
|
||||
result.current.closeResumeDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isResumeDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should maintain stable function references across renders', () => {
|
||||
const { result, rerender } = renderHook(() => useResumeCommand());
|
||||
|
||||
const initialOpenFn = result.current.openResumeDialog;
|
||||
const initialCloseFn = result.current.closeResumeDialog;
|
||||
const initialHandleResume = result.current.handleResume;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.openResumeDialog).toBe(initialOpenFn);
|
||||
expect(result.current.closeResumeDialog).toBe(initialCloseFn);
|
||||
expect(result.current.handleResume).toBe(initialHandleResume);
|
||||
});
|
||||
|
||||
it('handleResume no-ops when config is null', async () => {
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResumeCommand({
|
||||
config: null,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleResume('session-1');
|
||||
});
|
||||
|
||||
expect(startNewSession).not.toHaveBeenCalled();
|
||||
expect(historyManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(historyManager.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handleResume closes the dialog immediately and restores session state', async () => {
|
||||
resumeMocks.reset();
|
||||
resumeMocks.createPendingLoadSession();
|
||||
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
const geminiClient = {
|
||||
initialize: vi.fn(),
|
||||
};
|
||||
|
||||
const config = {
|
||||
getTargetDir: () => '/tmp',
|
||||
getGeminiClient: () => geminiClient,
|
||||
startNewSession: vi.fn(),
|
||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResumeCommand({
|
||||
config,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
// Open first so we can verify the dialog closes immediately.
|
||||
act(() => {
|
||||
result.current.openResumeDialog();
|
||||
});
|
||||
expect(result.current.isResumeDialogOpen).toBe(true);
|
||||
|
||||
let resumePromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
// Start resume but do not await it yet — we want to assert the dialog
|
||||
// closes immediately before the async session load completes.
|
||||
resumePromise = result.current.handleResume('session-2') as unknown as
|
||||
| Promise<void>
|
||||
| undefined;
|
||||
});
|
||||
expect(result.current.isResumeDialogOpen).toBe(false);
|
||||
|
||||
// Now finish the async load and let the handler complete.
|
||||
resumeMocks.resolvePendingLoadSession({
|
||||
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
});
|
||||
await act(async () => {
|
||||
await resumePromise;
|
||||
});
|
||||
|
||||
expect(config.startNewSession).toHaveBeenCalledWith(
|
||||
'session-2',
|
||||
expect.objectContaining({
|
||||
conversation: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(startNewSession).toHaveBeenCalledWith('session-2');
|
||||
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
|
||||
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
82
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
82
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
||||
export interface UseResumeCommandOptions {
|
||||
config: Config | null;
|
||||
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
|
||||
startNewSession: (sessionId: string) => void;
|
||||
remount?: () => void;
|
||||
}
|
||||
|
||||
export interface UseResumeCommandResult {
|
||||
isResumeDialogOpen: boolean;
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function useResumeCommand(
|
||||
options?: UseResumeCommandOptions,
|
||||
): UseResumeCommandResult {
|
||||
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
|
||||
|
||||
const openResumeDialog = useCallback(() => {
|
||||
setIsResumeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeResumeDialog = useCallback(() => {
|
||||
setIsResumeDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const { config, historyManager, startNewSession, remount } = options ?? {};
|
||||
|
||||
const handleResume = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!config || !historyManager || !startNewSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog immediately to prevent input capture during async operations.
|
||||
closeResumeDialog();
|
||||
|
||||
const cwd = config.getTargetDir();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new session in UI context.
|
||||
startNewSession(sessionId);
|
||||
|
||||
// Reset UI history.
|
||||
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
|
||||
historyManager.clearItems();
|
||||
historyManager.loadHistory(uiHistoryItems);
|
||||
|
||||
// Update session history core.
|
||||
config.startNewSession(sessionId, sessionData);
|
||||
await config.getGeminiClient()?.initialize?.();
|
||||
|
||||
// Refresh terminal UI.
|
||||
remount?.();
|
||||
},
|
||||
[closeResumeDialog, config, historyManager, startNewSession, remount],
|
||||
);
|
||||
|
||||
return {
|
||||
isResumeDialogOpen,
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
};
|
||||
}
|
||||
279
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
279
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unified session picker hook for both dialog and standalone modes.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app.
|
||||
* - Standalone mode should wrap the picker in `<KeypressProvider>` when rendered
|
||||
* outside the main app.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
ListSessionsResult,
|
||||
SessionListItem,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
filterSessions,
|
||||
SESSION_PAGE_SIZE,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
|
||||
export interface UseSessionPickerOptions {
|
||||
sessionService: SessionService | null;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
maxVisibleItems: number;
|
||||
/**
|
||||
* If true, computes centered scroll offset (keeps selection near middle).
|
||||
* If false, uses follow mode (scrolls when selection reaches edge).
|
||||
*/
|
||||
centerSelection?: boolean;
|
||||
/**
|
||||
* Enable/disable input handling.
|
||||
*/
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UseSessionPickerResult {
|
||||
selectedIndex: number;
|
||||
sessionState: SessionState;
|
||||
filteredSessions: SessionListItem[];
|
||||
filterByBranch: boolean;
|
||||
isLoading: boolean;
|
||||
scrollOffset: number;
|
||||
visibleSessions: SessionListItem[];
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
loadMoreSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection = false,
|
||||
isActive = true,
|
||||
}: UseSessionPickerOptions): UseSessionPickerResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
hasMore: true,
|
||||
nextCursor: undefined,
|
||||
});
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// For follow mode (non-centered)
|
||||
const [followScrollOffset, setFollowScrollOffset] = useState(0);
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
|
||||
[sessionState.sessions, filterByBranch, currentBranch],
|
||||
);
|
||||
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (centerSelection) {
|
||||
if (filteredSessions.length <= maxVisibleItems) {
|
||||
return 0;
|
||||
}
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
}
|
||||
return followScrollOffset;
|
||||
}, [
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
followScrollOffset,
|
||||
maxVisibleItems,
|
||||
selectedIndex,
|
||||
]);
|
||||
|
||||
const visibleSessions = useMemo(
|
||||
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
|
||||
[filteredSessions, maxVisibleItems, scrollOffset],
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!sessionService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitialSessions = async () => {
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
});
|
||||
setSessionState({
|
||||
sessions: result.items,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialSessions();
|
||||
}, [sessionService]);
|
||||
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
cursor: sessionState.nextCursor,
|
||||
});
|
||||
setSessionState((prev) => ({
|
||||
sessions: [...prev.sessions, ...result.items],
|
||||
hasMore: result.hasMore && result.nextCursor !== undefined,
|
||||
nextCursor: result.nextCursor,
|
||||
}));
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setFollowScrollOffset(0);
|
||||
}, [filterByBranch]);
|
||||
|
||||
// Ensure selectedIndex is valid when filtered sessions change
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex >= filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
) {
|
||||
setSelectedIndex(filteredSessions.length - 1);
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Auto-load more when centered mode hits the sentinel or list is empty.
|
||||
useEffect(() => {
|
||||
if (
|
||||
isLoading ||
|
||||
!sessionState.hasMore ||
|
||||
isLoadingMoreRef.current ||
|
||||
!centerSelection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinelVisible =
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
|
||||
|
||||
if (shouldLoadMore) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
isLoading,
|
||||
loadMoreSessions,
|
||||
maxVisibleItems,
|
||||
scrollOffset,
|
||||
sessionState.hasMore,
|
||||
]);
|
||||
|
||||
// Key handling (KeypressContext)
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const { name, sequence, ctrl } = key;
|
||||
|
||||
if (name === 'escape' || (ctrl && name === 'c')) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'return') {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
onSelect(session.sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'up' || name === 'k') {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.max(0, prev - 1);
|
||||
if (!centerSelection && newIndex < followScrollOffset) {
|
||||
setFollowScrollOffset(newIndex);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'down' || name === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
|
||||
|
||||
if (
|
||||
!centerSelection &&
|
||||
newIndex >= followScrollOffset + maxVisibleItems
|
||||
) {
|
||||
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
|
||||
}
|
||||
|
||||
// Follow mode: load more when near the end.
|
||||
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sequence === 'b' || sequence === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
sessionState,
|
||||
filteredSessions,
|
||||
filterByBranch,
|
||||
isLoading,
|
||||
scrollOffset,
|
||||
visibleSessions,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
loadMoreSessions,
|
||||
};
|
||||
}
|
||||
45
packages/cli/src/ui/utils/sessionPickerUtils.test.ts
Normal file
45
packages/cli/src/ui/utils/sessionPickerUtils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { truncateText } from './sessionPickerUtils.js';
|
||||
|
||||
describe('sessionPickerUtils', () => {
|
||||
describe('truncateText', () => {
|
||||
it('returns the original text when it fits and has no newline', () => {
|
||||
expect(truncateText('hello', 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates long text with ellipsis', () => {
|
||||
expect(truncateText('hello world', 5)).toBe('he...');
|
||||
});
|
||||
|
||||
it('truncates without ellipsis when maxWidth <= 3', () => {
|
||||
expect(truncateText('hello', 3)).toBe('hel');
|
||||
expect(truncateText('hello', 2)).toBe('he');
|
||||
});
|
||||
|
||||
it('breaks at newline and returns only the first line', () => {
|
||||
expect(truncateText('hello\nworld', 20)).toBe('hello');
|
||||
expect(truncateText('hello\r\nworld', 20)).toBe('hello');
|
||||
});
|
||||
|
||||
it('breaks at newline and still truncates the first line when needed', () => {
|
||||
expect(truncateText('hello\nworld', 2)).toBe('he');
|
||||
expect(truncateText('hello\nworld', 3)).toBe('hel');
|
||||
expect(truncateText('hello\nworld', 4)).toBe('h...');
|
||||
});
|
||||
|
||||
it('does not add ellipsis when the string ends at a newline', () => {
|
||||
expect(truncateText('hello\n', 20)).toBe('hello');
|
||||
expect(truncateText('hello\r\n', 20)).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns only the first line even if there are multiple line breaks', () => {
|
||||
expect(truncateText('hello\n\nworld', 20)).toBe('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
59
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
59
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SessionListItem } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* State for managing loaded sessions in the session picker.
|
||||
*/
|
||||
export interface SessionState {
|
||||
sessions: SessionListItem[];
|
||||
hasMore: boolean;
|
||||
nextCursor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page size for loading sessions.
|
||||
*/
|
||||
export const SESSION_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Truncates text to fit within a given width, adding ellipsis if needed.
|
||||
*/
|
||||
export function truncateText(text: string, maxWidth: number): string {
|
||||
const firstLine = text.split(/\r?\n/, 1)[0];
|
||||
if (firstLine.length <= maxWidth) {
|
||||
return firstLine;
|
||||
}
|
||||
if (maxWidth <= 3) {
|
||||
return firstLine.slice(0, maxWidth);
|
||||
}
|
||||
return firstLine.slice(0, maxWidth - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters sessions optionally by branch.
|
||||
*/
|
||||
export function filterSessions(
|
||||
sessions: SessionListItem[],
|
||||
filterByBranch: boolean,
|
||||
currentBranch?: string,
|
||||
): SessionListItem[] {
|
||||
return sessions.filter((session) => {
|
||||
// Apply branch filter if enabled
|
||||
if (filterByBranch && currentBranch) {
|
||||
return session.gitBranch === currentBranch;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats message count for display with proper pluralization.
|
||||
*/
|
||||
export function formatMessageCount(count: number): string {
|
||||
return count === 1 ? '1 message' : `${count} messages`;
|
||||
}
|
||||
@@ -76,6 +76,105 @@ describe('getGitHubRepoInfo', async () => {
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
// Tests for credential formats
|
||||
|
||||
it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with username:password format', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://username:password@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
// Tests for case insensitivity
|
||||
|
||||
it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://GITHUB.COM/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('returns the owner and repo for URL with mixed case GitHub.Com', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://GitHub.Com/owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
// Tests for SSH format
|
||||
|
||||
it('returns the owner and repo for SSH URL', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'git@github.com:owner/repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('throws for non-GitHub SSH URL', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'git@gitlab.com:owner/repo.git',
|
||||
);
|
||||
expect(() => {
|
||||
getGitHubRepoInfo();
|
||||
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
|
||||
});
|
||||
|
||||
// Tests for edge cases
|
||||
|
||||
it('returns the owner and repo for URL without .git suffix', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github.com/owner/repo',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('throws for non-GitHub HTTPS URL', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://gitlab.com/owner/repo.git',
|
||||
);
|
||||
expect(() => {
|
||||
getGitHubRepoInfo();
|
||||
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
|
||||
});
|
||||
|
||||
it('handles repo names containing .git substring', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github.com/owner/my.git.repo.git',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'my.git.repo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitRepoRoot', async () => {
|
||||
|
||||
@@ -103,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
||||
const match = remoteUrl.match(
|
||||
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
|
||||
);
|
||||
|
||||
// If the regex fails match, throw an error.
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
// Handle SCP-style SSH URLs (git@github.com:owner/repo.git)
|
||||
let urlToParse = remoteUrl;
|
||||
if (remoteUrl.startsWith('git@github.com:')) {
|
||||
urlToParse = remoteUrl.replace('git@github.com:', '');
|
||||
} else if (remoteUrl.startsWith('git@')) {
|
||||
// SSH URL for a different provider (GitLab, Bitbucket, etc.)
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { owner: match[1], repo: match[2] };
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(urlToParse, 'https://github.com');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedUrl.host !== 'github.com') {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parts = parsedUrl.pathname.split('/').filter((part) => part !== '');
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') };
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
|
||||
process.execArgv = [...originalExecArgv];
|
||||
process.argv = [...originalArgv];
|
||||
@@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => {
|
||||
stdinResumeSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
|
||||
describe('when QWEN_CODE_NO_RELAUNCH is set', () => {
|
||||
it('should return early without spawning a child process', async () => {
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||
|
||||
await relaunchAppInChildProcess(['--test'], ['--verbose']);
|
||||
|
||||
@@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
|
||||
describe('when QWEN_CODE_NO_RELAUNCH is not set', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||
});
|
||||
|
||||
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess(
|
||||
additionalNodeArgs: string[],
|
||||
additionalScriptArgs: string[],
|
||||
) {
|
||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
||||
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess(
|
||||
...additionalScriptArgs,
|
||||
...scriptArgs,
|
||||
];
|
||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
||||
const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' };
|
||||
|
||||
// The parent process should not be reading from stdin while the child is running.
|
||||
process.stdin.pause();
|
||||
|
||||
@@ -287,7 +287,7 @@ export interface ConfigParameters {
|
||||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
telemetry?: TelemetrySettings;
|
||||
gitCoAuthor?: GitCoAuthorSettings;
|
||||
gitCoAuthor?: boolean;
|
||||
usageStatisticsEnabled?: boolean;
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
@@ -534,9 +534,9 @@ export class Config {
|
||||
useCollector: params.telemetry?.useCollector,
|
||||
};
|
||||
this.gitCoAuthor = {
|
||||
enabled: params.gitCoAuthor?.enabled ?? true,
|
||||
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
|
||||
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
|
||||
enabled: params.gitCoAuthor ?? true,
|
||||
name: 'Qwen-Coder',
|
||||
email: 'qwen-coder@alibabacloud.com',
|
||||
};
|
||||
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
||||
|
||||
@@ -741,9 +741,12 @@ export class Config {
|
||||
/**
|
||||
* Starts a new session and resets session-scoped services.
|
||||
*/
|
||||
startNewSession(sessionId?: string): string {
|
||||
startNewSession(
|
||||
sessionId?: string,
|
||||
sessionData?: ResumedSessionData,
|
||||
): string {
|
||||
this.sessionId = sessionId ?? randomUUID();
|
||||
this.sessionData = undefined;
|
||||
this.sessionData = sessionData;
|
||||
this.chatRecordingService = this.chatRecordingEnabled
|
||||
? new ChatRecordingService(this)
|
||||
: undefined;
|
||||
|
||||
@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
|
||||
};
|
||||
proxy?: string | undefined;
|
||||
userAgent?: string;
|
||||
// Schema compliance mode for tool definitions
|
||||
schemaCompliance?: 'auto' | 'openapi_30';
|
||||
};
|
||||
|
||||
export function createContentGeneratorConfig(
|
||||
|
||||
@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import {
|
||||
convertSchema,
|
||||
type SchemaComplianceMode,
|
||||
} from '../../utils/schemaConverter.js';
|
||||
|
||||
/**
|
||||
* Extended usage type that supports both OpenAI standard format and alternative formats
|
||||
@@ -80,11 +84,13 @@ interface ParsedParts {
|
||||
*/
|
||||
export class OpenAIContentConverter {
|
||||
private model: string;
|
||||
private schemaCompliance: SchemaComplianceMode;
|
||||
private streamingToolCallParser: StreamingToolCallParser =
|
||||
new StreamingToolCallParser();
|
||||
|
||||
constructor(model: string) {
|
||||
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
|
||||
this.model = model;
|
||||
this.schemaCompliance = schemaCompliance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters) {
|
||||
parameters = convertSchema(parameters, this.schemaCompliance);
|
||||
}
|
||||
|
||||
openAITools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
|
||||
@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct configuration', () => {
|
||||
expect(mockProvider.buildClient).toHaveBeenCalled();
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith(
|
||||
'test-model',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
|
||||
this.client = this.config.provider.buildClient();
|
||||
this.converter = new OpenAIContentConverter(
|
||||
this.contentGeneratorConfig.model,
|
||||
this.contentGeneratorConfig.schemaCompliance,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -608,6 +608,36 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle git commit with combined short flags like -am', async () => {
|
||||
const command = 'git commit -am "Add feature"';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify non-git commands', async () => {
|
||||
const command = 'npm install';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
@@ -768,6 +798,69 @@ describe('ShellTool', () => {
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author when git commit is prefixed with cd command', async () => {
|
||||
const command = 'cd /tmp/test && git commit -m "Test commit"';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author to git commit with multi-line message', async () => {
|
||||
const command = `git commit -m "Fix bug
|
||||
|
||||
This is a detailed description
|
||||
spanning multiple lines"`;
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -334,13 +334,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
private addCoAuthorToGitCommit(command: string): string {
|
||||
// Check if co-author feature is enabled
|
||||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||||
|
||||
if (!gitCoAuthorSettings.enabled) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Check if this is a git commit command
|
||||
const gitCommitPattern = /^git\s+commit/;
|
||||
if (!gitCommitPattern.test(command.trim())) {
|
||||
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
|
||||
const gitCommitPattern = /\bgit\s+commit\b/;
|
||||
if (!gitCommitPattern.test(command)) {
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -349,15 +350,27 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||
|
||||
// Handle different git commit patterns
|
||||
// Match -m "message" or -m 'message'
|
||||
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
|
||||
const match = command.match(messagePattern);
|
||||
// Handle different git commit patterns:
|
||||
// Match -m "message" or -m 'message', including combined flags like -am
|
||||
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
|
||||
//
|
||||
// Pattern breakdown:
|
||||
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
|
||||
// \s+ matches whitespace after the flag
|
||||
// [^"\\] matches any char except double-quote and backslash
|
||||
// \\. matches escape sequences like \" or \\
|
||||
// (?:...|...)* matches normal chars or escapes, repeated
|
||||
const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/;
|
||||
const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/;
|
||||
const doubleMatch = command.match(doubleQuotePattern);
|
||||
const singleMatch = command.match(singleQuotePattern);
|
||||
const match = doubleMatch ?? singleMatch;
|
||||
const quote = doubleMatch ? '"' : "'";
|
||||
|
||||
if (match) {
|
||||
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
|
||||
const [fullMatch, prefix, existingMessage] = match;
|
||||
const newMessage = existingMessage + coAuthor;
|
||||
const replacement = prefix + quote + newMessage + closingQuote;
|
||||
const replacement = prefix + quote + newMessage + quote;
|
||||
|
||||
return command.replace(fullMatch, replacement);
|
||||
}
|
||||
|
||||
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertSchema } from './schemaConverter.js';
|
||||
|
||||
describe('convertSchema', () => {
|
||||
describe('mode: auto (default)', () => {
|
||||
it('should preserve type arrays', () => {
|
||||
const input = { type: ['string', 'null'] };
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve items array (tuples)', () => {
|
||||
const input = {
|
||||
type: 'array',
|
||||
items: [{ type: 'string' }, { type: 'number' }],
|
||||
};
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve mixed enums', () => {
|
||||
const input = { enum: [1, 2, '3'] };
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve unsupported keywords', () => {
|
||||
const input = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
exclusiveMinimum: 10,
|
||||
type: 'number',
|
||||
};
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode: openapi_30 (strict)', () => {
|
||||
it('should convert type arrays to nullable', () => {
|
||||
const input = { type: ['string', 'null'] };
|
||||
const expected = { type: 'string', nullable: true };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should fallback to first type for non-nullable arrays', () => {
|
||||
const input = { type: ['string', 'number'] };
|
||||
const expected = { type: 'string' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert const to enum', () => {
|
||||
const input = { const: 'foo' };
|
||||
const expected = { enum: ['foo'] };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert exclusiveMinimum number to boolean', () => {
|
||||
const input = { type: 'number', exclusiveMinimum: 10 };
|
||||
const expected = {
|
||||
type: 'number',
|
||||
minimum: 10,
|
||||
exclusiveMinimum: true,
|
||||
};
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert nested objects recursively', () => {
|
||||
const input = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 },
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prop1: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
maximum: 5,
|
||||
exclusiveMaximum: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify enums', () => {
|
||||
const input = { enum: [1, 2, '3'] };
|
||||
const expected = { enum: ['1', '2', '3'] };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove tuple items (array of schemas)', () => {
|
||||
const input = {
|
||||
type: 'array',
|
||||
items: [{ type: 'string' }, { type: 'number' }],
|
||||
};
|
||||
const expected = { type: 'array' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove unsupported keywords', () => {
|
||||
const input = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
$id: '#foo',
|
||||
type: 'string',
|
||||
default: 'bar',
|
||||
dependencies: { foo: ['bar'] },
|
||||
patternProperties: { '^foo': { type: 'string' } },
|
||||
};
|
||||
const expected = { type: 'string' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/core/src/utils/schemaConverter.ts
Normal file
135
packages/core/src/utils/schemaConverter.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility for converting JSON Schemas to be compatible with different LLM providers.
|
||||
* Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to
|
||||
* OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API.
|
||||
*/
|
||||
|
||||
export type SchemaComplianceMode = 'auto' | 'openapi_30';
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema to be compatible with the specified compliance mode.
|
||||
*/
|
||||
export function convertSchema(
|
||||
schema: Record<string, unknown>,
|
||||
mode: SchemaComplianceMode = 'auto',
|
||||
): Record<string, unknown> {
|
||||
if (mode === 'openapi_30') {
|
||||
return toOpenAPI30(schema);
|
||||
}
|
||||
|
||||
// Default ('auto') mode now does nothing.
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Modern JSON Schema to OpenAPI 3.0 Schema Object.
|
||||
* Attempts to preserve semantics where possible through transformations.
|
||||
*/
|
||||
function toOpenAPI30(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
const convert = (obj: unknown): unknown => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convert);
|
||||
}
|
||||
|
||||
const source = obj as Record<string, unknown>;
|
||||
const target: Record<string, unknown> = {};
|
||||
|
||||
// 1. Type Handling
|
||||
if (Array.isArray(source['type'])) {
|
||||
const types = source['type'] as string[];
|
||||
// Handle ["string", "null"] pattern common in modern schemas
|
||||
if (types.length === 2 && types.includes('null')) {
|
||||
target['type'] = types.find((t) => t !== 'null');
|
||||
target['nullable'] = true;
|
||||
} else {
|
||||
// Fallback for other unions: take the first non-null type
|
||||
// OpenAPI 3.0 doesn't support type arrays.
|
||||
// Ideal fix would be anyOf, but simple fallback is safer for now.
|
||||
target['type'] = types[0];
|
||||
}
|
||||
} else if (source['type'] !== undefined) {
|
||||
target['type'] = source['type'];
|
||||
}
|
||||
|
||||
// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
|
||||
if (source['const'] !== undefined) {
|
||||
target['enum'] = [source['const']];
|
||||
delete target['const'];
|
||||
}
|
||||
|
||||
// 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean)
|
||||
// exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true
|
||||
if (typeof source['exclusiveMinimum'] === 'number') {
|
||||
target['minimum'] = source['exclusiveMinimum'];
|
||||
target['exclusiveMinimum'] = true;
|
||||
}
|
||||
if (typeof source['exclusiveMaximum'] === 'number') {
|
||||
target['maximum'] = source['exclusiveMaximum'];
|
||||
target['exclusiveMaximum'] = true;
|
||||
}
|
||||
|
||||
// 4. Array Items (Tuple -> Single Schema)
|
||||
// OpenAPI 3.0 items must be a schema object, not an array of schemas
|
||||
if (Array.isArray(source['items'])) {
|
||||
// Tuple support is tricky.
|
||||
// Best effort: Use the first item's schema as a generic array type
|
||||
// or convert to an empty object (any type) if mixed.
|
||||
// For now, we'll strip it to allow validation to pass (accepts any items)
|
||||
// This matches the legacy behavior but is explicit.
|
||||
// Ideally, we could use `oneOf` on the items if we wanted to be stricter.
|
||||
delete target['items'];
|
||||
} else if (
|
||||
typeof source['items'] === 'object' &&
|
||||
source['items'] !== null
|
||||
) {
|
||||
target['items'] = convert(source['items']);
|
||||
}
|
||||
|
||||
// 5. Enum Stringification
|
||||
// Gemini strictly requires enums to be strings
|
||||
if (Array.isArray(source['enum'])) {
|
||||
target['enum'] = source['enum'].map(String);
|
||||
}
|
||||
|
||||
// 6. Recursively process other properties
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
// Skip fields we've already handled or want to remove
|
||||
if (
|
||||
key === 'type' ||
|
||||
key === 'const' ||
|
||||
key === 'exclusiveMinimum' ||
|
||||
key === 'exclusiveMaximum' ||
|
||||
key === 'items' ||
|
||||
key === 'enum' ||
|
||||
key === '$schema' ||
|
||||
key === '$id' ||
|
||||
key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types
|
||||
key === 'dependencies' ||
|
||||
key === 'patternProperties'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target[key] = convert(value);
|
||||
}
|
||||
|
||||
// Preserve default if it doesn't conflict (simple pass-through)
|
||||
// if (source['default'] !== undefined) {
|
||||
// target['default'] = source['default'];
|
||||
// }
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
return convert(schema) as Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user