mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge pull request #1239 from afarber/1179-add-resume-cmd
feat(ui): add /resume slash command to switch between sessions
This commit is contained in:
@@ -20,10 +20,11 @@ These commands help you save, restore, and summarize work progress.
|
|||||||
|
|
||||||
| Command | Description | Usage Examples |
|
| Command | Description | Usage Examples |
|
||||||
| ----------- | --------------------------------------------------------- | ------------------------------------ |
|
| ----------- | --------------------------------------------------------- | ------------------------------------ |
|
||||||
|
| `/init` | Analyze current directory and create initial context file | `/init` |
|
||||||
| `/summary` | Generate project summary based on conversation history | `/summary` |
|
| `/summary` | Generate project summary based on conversation history | `/summary` |
|
||||||
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
|
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
|
||||||
|
| `/resume` | Resume a previous conversation session | `/resume` |
|
||||||
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
|
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
|
||||||
| `/init` | Analyze current directory and create initial context file | `/init` |
|
|
||||||
|
|
||||||
### 1.2 Interface and Workspace Control
|
### 1.2 Interface and Workspace Control
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
|||||||
import { getCliVersion } from './utils/version.js';
|
import { getCliVersion } from './utils/version.js';
|
||||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||||
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
|
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
order: string | undefined,
|
order: string | undefined,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
|
|||||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||||
|
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||||
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||||
quitCommand,
|
quitCommand,
|
||||||
restoreCommand(this.config),
|
restoreCommand(this.config),
|
||||||
|
resumeCommand,
|
||||||
statsCommand,
|
statsCommand,
|
||||||
summaryCommand,
|
summaryCommand,
|
||||||
themeCommand,
|
themeCommand,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
|
|||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||||
|
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useVimMode } from './contexts/VimModeContext.js';
|
import { useVimMode } from './contexts/VimModeContext.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
@@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
|
|
||||||
// Additional hooks moved from App.tsx
|
// Additional hooks moved from App.tsx
|
||||||
const { stats: sessionStats } = useSessionStats();
|
const { stats: sessionStats, startNewSession } = useSessionStats();
|
||||||
const logger = useLogger(config.storage, sessionStats.sessionId);
|
const logger = useLogger(config.storage, sessionStats.sessionId);
|
||||||
const branchName = useGitBranchName(config.getTargetDir());
|
const branchName = useGitBranchName(config.getTargetDir());
|
||||||
|
|
||||||
@@ -435,6 +436,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||||
useModelCommand();
|
useModelCommand();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isResumeDialogOpen,
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResume,
|
||||||
|
} = useResumeCommand({
|
||||||
|
config,
|
||||||
|
historyManager,
|
||||||
|
startNewSession,
|
||||||
|
remount: refreshStatic,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showWorkspaceMigrationDialog,
|
showWorkspaceMigrationDialog,
|
||||||
workspaceExtensions,
|
workspaceExtensions,
|
||||||
@@ -488,6 +501,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
openAgentsManagerDialog,
|
openAgentsManagerDialog,
|
||||||
|
openResumeDialog,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
@@ -502,6 +516,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
openAgentsManagerDialog,
|
openAgentsManagerDialog,
|
||||||
|
openResumeDialog,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1194,7 +1209,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
!!proQuotaRequest ||
|
!!proQuotaRequest ||
|
||||||
isSubagentCreateDialogOpen ||
|
isSubagentCreateDialogOpen ||
|
||||||
isAgentsManagerDialogOpen ||
|
isAgentsManagerDialogOpen ||
|
||||||
isApprovalModeDialogOpen;
|
isApprovalModeDialogOpen ||
|
||||||
|
isResumeDialogOpen;
|
||||||
|
|
||||||
const pendingHistoryItems = useMemo(
|
const pendingHistoryItems = useMemo(
|
||||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||||
@@ -1222,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
|
isResumeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1312,6 +1329,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
|
isResumeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog,
|
closeSubagentCreateDialog,
|
||||||
closeAgentsManagerDialog,
|
closeAgentsManagerDialog,
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResume,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
@@ -1453,6 +1475,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog,
|
closeSubagentCreateDialog,
|
||||||
closeAgentsManagerDialog,
|
closeAgentsManagerDialog,
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResume,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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_create'
|
||||||
| 'subagent_list'
|
| 'subagent_list'
|
||||||
| 'permissions'
|
| 'permissions'
|
||||||
| 'approval-mode';
|
| 'approval-mode'
|
||||||
|
| 'resume';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
|||||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||||
|
import { SessionPicker } from './SessionPicker.js';
|
||||||
|
|
||||||
interface DialogManagerProps {
|
interface DialogManagerProps {
|
||||||
addItem: UseHistoryManagerReturn['addItem'];
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog: () => void;
|
closeSubagentCreateDialog: () => void;
|
||||||
closeAgentsManagerDialog: () => void;
|
closeAgentsManagerDialog: () => void;
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog: () => void;
|
||||||
|
closeResumeDialog: () => void;
|
||||||
|
handleResume: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface UIState {
|
|||||||
isModelDialogOpen: boolean;
|
isModelDialogOpen: boolean;
|
||||||
isPermissionsDialogOpen: boolean;
|
isPermissionsDialogOpen: boolean;
|
||||||
isApprovalModeDialogOpen: boolean;
|
isApprovalModeDialogOpen: boolean;
|
||||||
|
isResumeDialogOpen: boolean;
|
||||||
slashCommands: readonly SlashCommand[];
|
slashCommands: readonly SlashCommand[];
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
commandContext: CommandContext;
|
commandContext: CommandContext;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
|||||||
'clear',
|
'clear',
|
||||||
'reset',
|
'reset',
|
||||||
'new',
|
'new',
|
||||||
|
'resume',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface SlashCommandProcessorActions {
|
interface SlashCommandProcessorActions {
|
||||||
@@ -66,6 +67,7 @@ interface SlashCommandProcessorActions {
|
|||||||
openModelDialog: () => void;
|
openModelDialog: () => void;
|
||||||
openPermissionsDialog: () => void;
|
openPermissionsDialog: () => void;
|
||||||
openApprovalModeDialog: () => void;
|
openApprovalModeDialog: () => void;
|
||||||
|
openResumeDialog: () => void;
|
||||||
quit: (messages: HistoryItem[]) => void;
|
quit: (messages: HistoryItem[]) => void;
|
||||||
setDebugMessage: (message: string) => void;
|
setDebugMessage: (message: string) => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
@@ -417,6 +419,9 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'approval-mode':
|
case 'approval-mode':
|
||||||
actions.openApprovalModeDialog();
|
actions.openApprovalModeDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'resume':
|
||||||
|
actions.openResumeDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
case 'help':
|
case 'help':
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
default: {
|
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`;
|
||||||
|
}
|
||||||
@@ -741,9 +741,12 @@ export class Config {
|
|||||||
/**
|
/**
|
||||||
* Starts a new session and resets session-scoped services.
|
* Starts a new session and resets session-scoped services.
|
||||||
*/
|
*/
|
||||||
startNewSession(sessionId?: string): string {
|
startNewSession(
|
||||||
|
sessionId?: string,
|
||||||
|
sessionData?: ResumedSessionData,
|
||||||
|
): string {
|
||||||
this.sessionId = sessionId ?? randomUUID();
|
this.sessionId = sessionId ?? randomUUID();
|
||||||
this.sessionData = undefined;
|
this.sessionData = sessionData;
|
||||||
this.chatRecordingService = this.chatRecordingEnabled
|
this.chatRecordingService = this.chatRecordingEnabled
|
||||||
? new ChatRecordingService(this)
|
? new ChatRecordingService(this)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user