mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
rework /resume slash command
This commit is contained in:
@@ -99,6 +99,7 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
import { useSessionSelect } from './hooks/useSessionSelect.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
@@ -439,60 +440,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } =
|
||||
useResumeCommand();
|
||||
|
||||
// Handle resume session selection
|
||||
const handleResumeSessionSelect = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog immediately to prevent input capture during async operations
|
||||
closeResumeDialog();
|
||||
|
||||
const {
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation,
|
||||
replayUiTelemetryFromConversation,
|
||||
uiTelemetryService,
|
||||
} = await import('@qwen-code/qwen-code-core');
|
||||
const { buildResumedHistoryItems } = await import(
|
||||
'./utils/resumeHistoryUtils.js'
|
||||
);
|
||||
|
||||
const cwd = config.getTargetDir();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset and replay UI telemetry to restore metrics
|
||||
uiTelemetryService.reset();
|
||||
replayUiTelemetryFromConversation(sessionData.conversation);
|
||||
|
||||
// Build UI history items using existing utility
|
||||
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
|
||||
|
||||
// Build API history for the LLM client
|
||||
const clientHistory = buildApiHistoryFromConversation(
|
||||
sessionData.conversation,
|
||||
);
|
||||
|
||||
// Update client history
|
||||
config.getGeminiClient()?.setHistory(clientHistory);
|
||||
config.getGeminiClient()?.stripThoughtsFromHistory();
|
||||
|
||||
// Update session in config
|
||||
config.startNewSession(sessionId);
|
||||
startNewSession(sessionId);
|
||||
|
||||
// Clear and load history
|
||||
historyManager.clearItems();
|
||||
historyManager.loadHistory(uiHistoryItems);
|
||||
},
|
||||
[config, closeResumeDialog, historyManager, startNewSession],
|
||||
);
|
||||
const handleResumeSessionSelect = useSessionSelect({
|
||||
config,
|
||||
historyManager,
|
||||
closeResumeDialog,
|
||||
startNewSession,
|
||||
remount: refreshStatic,
|
||||
});
|
||||
|
||||
const {
|
||||
showWorkspaceMigrationDialog,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType, getGitBranch } from '@qwen-code/qwen-code-core';
|
||||
import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
@@ -36,7 +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 { ResumeSessionDialog } from './ResumeSessionDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -293,13 +293,11 @@ export const DialogManager = ({
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<ResumeSessionDialog
|
||||
cwd={config.getTargetDir()}
|
||||
<SessionPicker
|
||||
sessionService={config.getSessionService()}
|
||||
currentBranch={getGitBranch(config.getTargetDir())}
|
||||
onSelect={uiActions.handleResumeSessionSelect}
|
||||
onCancel={uiActions.closeResumeDialog}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* @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 { ResumeSessionDialog } from './ResumeSessionDialog.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import type {
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
// Mock SessionService and getGitBranch
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
SessionService: vi.fn().mockImplementation(() => mockSessionService),
|
||||
getGitBranch: vi.fn().mockReturnValue('main'),
|
||||
};
|
||||
});
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// Default mock session service
|
||||
let mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
describe('ResumeSessionDialog', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state initially', () => {
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Resume Session');
|
||||
expect(output).toContain('Loading sessions...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show "No sessions found" when there are no sessions', async () => {
|
||||
mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('No sessions found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Display', () => {
|
||||
it('should display sessions after loading', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'session-1',
|
||||
prompt: 'First session prompt',
|
||||
messageCount: 10,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'session-2',
|
||||
prompt: 'Second session prompt',
|
||||
messageCount: 5,
|
||||
}),
|
||||
];
|
||||
|
||||
mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: sessions,
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('First session prompt');
|
||||
});
|
||||
|
||||
it('should filter out empty sessions', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'empty-session',
|
||||
prompt: '',
|
||||
messageCount: 0,
|
||||
}),
|
||||
createMockSession({
|
||||
sessionId: 'valid-session',
|
||||
prompt: 'Valid prompt',
|
||||
messageCount: 5,
|
||||
}),
|
||||
];
|
||||
|
||||
mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: sessions,
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Valid prompt');
|
||||
// Empty session should be filtered out
|
||||
expect(output).not.toContain('empty-session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer', () => {
|
||||
it('should show navigation instructions in footer', async () => {
|
||||
mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: [createMockSession()],
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('to navigate');
|
||||
expect(output).toContain('Enter to select');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should show branch toggle hint when currentBranch is available', async () => {
|
||||
mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: [createMockSession()],
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show B key hint since getGitBranch is mocked to return 'main'
|
||||
expect(output).toContain('B');
|
||||
expect(output).toContain('toggle branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal Height', () => {
|
||||
it('should accept availableTerminalHeight prop', async () => {
|
||||
mockSessionService = {
|
||||
listSessions: vi.fn().mockResolvedValue({
|
||||
items: [createMockSession()],
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
} as ListSessionsResult),
|
||||
};
|
||||
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
// Should not throw with availableTerminalHeight prop
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ResumeSessionDialog
|
||||
cwd="/test/path"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
availableTerminalHeight={20}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Resume Session');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js';
|
||||
import { SessionListItemView } from './SessionListItem.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export interface ResumeSessionDialogProps {
|
||||
cwd: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
export function ResumeSessionDialog({
|
||||
cwd,
|
||||
onSelect,
|
||||
onCancel,
|
||||
availableTerminalHeight,
|
||||
}: ResumeSessionDialogProps): React.JSX.Element {
|
||||
const sessionServiceRef = useRef<SessionService | null>(null);
|
||||
const [currentBranch, setCurrentBranch] = useState<string | undefined>();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Initialize session service
|
||||
useEffect(() => {
|
||||
sessionServiceRef.current = new SessionService(cwd);
|
||||
setCurrentBranch(getGitBranch(cwd));
|
||||
setIsReady(true);
|
||||
}, [cwd]);
|
||||
|
||||
// Calculate visible items based on terminal height
|
||||
const maxVisibleItems = availableTerminalHeight
|
||||
? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3))
|
||||
: 5;
|
||||
|
||||
const picker = useDialogSessionPicker({
|
||||
sessionService: sessionServiceRef.current,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection: false,
|
||||
isActive: isReady,
|
||||
});
|
||||
|
||||
if (!isReady || picker.isLoading) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Resume Session')}
|
||||
</Text>
|
||||
<Box paddingY={1}>
|
||||
<Text color={theme.text.secondary}>{t('Loading sessions...')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Resume Session')}
|
||||
</Text>
|
||||
{picker.filterByBranch && currentBranch && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{t('(branch: {{branch}})', { branch: currentBranch })}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Session List */}
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{picker.filteredSessions.length === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<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={(process.stdout.columns || 80) - 10}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderTop
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingTop={1}
|
||||
>
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentBranch && (
|
||||
<>
|
||||
<Text color={theme.text.accent} bold>
|
||||
B
|
||||
</Text>
|
||||
{t(' to toggle branch') + ' · '}
|
||||
</>
|
||||
)}
|
||||
{t('to navigate · Enter to select · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type { SessionListItem as SessionData } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatRelativeTime } from '../utils/formatters.js';
|
||||
import {
|
||||
truncateText,
|
||||
formatMessageCount,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
|
||||
export interface SessionListItemViewProps {
|
||||
session: SessionData;
|
||||
isSelected: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
maxPromptWidth: number;
|
||||
/**
|
||||
* Prefix characters for selection indicator and scroll hints.
|
||||
* Dialog style uses '> ', '^ ', 'v ' (ASCII).
|
||||
* Standalone style uses special Unicode characters.
|
||||
*/
|
||||
prefixChars?: {
|
||||
selected: string;
|
||||
scrollUp: string;
|
||||
scrollDown: string;
|
||||
normal: string;
|
||||
};
|
||||
/**
|
||||
* Whether to bold the prefix when selected.
|
||||
*/
|
||||
boldSelectedPrefix?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFIX_CHARS = {
|
||||
selected: '> ',
|
||||
scrollUp: '^ ',
|
||||
scrollDown: 'v ',
|
||||
normal: ' ',
|
||||
};
|
||||
|
||||
export function SessionListItemView({
|
||||
session,
|
||||
isSelected,
|
||||
isFirst,
|
||||
isLast,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
maxPromptWidth,
|
||||
prefixChars = DEFAULT_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}>
|
||||
{/* First line: prefix + prompt text */}
|
||||
<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>
|
||||
{/* Second line: metadata */}
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{timeAgo} · {messageText}
|
||||
{session.gitBranch && ` · ${session.gitBranch}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
275
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
275
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { 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 [terminalSize, setTerminalSize] = useState({
|
||||
width: process.stdout.columns || 80,
|
||||
height: process.stdout.rows || 24,
|
||||
});
|
||||
|
||||
// Keep fullscreen picker responsive to terminal resize.
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setTerminalSize({
|
||||
width: process.stdout.columns || 80,
|
||||
height: process.stdout.rows || 24,
|
||||
});
|
||||
};
|
||||
|
||||
// `stdout` emits "resize" when TTY size changes.
|
||||
process.stdout.on('resize', handleResize);
|
||||
return () => {
|
||||
process.stdout.off('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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((terminalSize.height - reservedLines) / itemHeight),
|
||||
);
|
||||
|
||||
const picker = useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const width = terminalSize.width;
|
||||
const height = terminalSize.height;
|
||||
|
||||
// Calculate content width (terminal width minus border padding)
|
||||
const contentWidth = width - 4;
|
||||
const promptMaxWidth = contentWidth - 4;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
height={height - 1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={width}
|
||||
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={promptMaxWidth}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,21 @@
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionPicker } from './StandaloneSessionPicker.js';
|
||||
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 };
|
||||
|
||||
@@ -68,8 +77,8 @@ describe('SessionPicker', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Empty Sessions Filtering', () => {
|
||||
it('should filter out sessions with 0 messages', async () => {
|
||||
describe('Empty Sessions', () => {
|
||||
it('should show sessions with 0 messages', async () => {
|
||||
const sessions = [
|
||||
createMockSession({
|
||||
sessionId: 'empty-1',
|
||||
@@ -92,24 +101,24 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show the session with messages
|
||||
expect(output).toContain('Hello');
|
||||
// Should NOT show empty sessions
|
||||
expect(output).not.toContain('empty-1');
|
||||
expect(output).not.toContain('empty-2');
|
||||
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
|
||||
expect(output).toContain('0 messages');
|
||||
});
|
||||
|
||||
it('should show "No sessions found" when all sessions are empty', async () => {
|
||||
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 }),
|
||||
@@ -119,17 +128,19 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('No sessions found');
|
||||
expect(output).toContain('0 messages');
|
||||
});
|
||||
|
||||
it('should show sessions with 1 or more messages', async () => {
|
||||
@@ -150,11 +161,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -194,12 +207,14 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
currentBranch="main"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -246,12 +261,14 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
currentBranch="main"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -261,9 +278,9 @@ describe('SessionPicker', () => {
|
||||
await wait(50);
|
||||
|
||||
const output = lastFrame();
|
||||
// Should only show non-empty sessions from main branch
|
||||
// Should only show sessions from main branch (including 0-message sessions)
|
||||
expect(output).toContain('Valid main');
|
||||
expect(output).not.toContain('Empty main');
|
||||
expect(output).toContain('Empty main');
|
||||
expect(output).not.toContain('Valid feature');
|
||||
});
|
||||
});
|
||||
@@ -292,11 +309,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -332,11 +351,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -365,11 +386,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -390,11 +413,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -423,11 +448,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -445,18 +472,20 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<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('↑↓ to navigate');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
@@ -467,12 +496,14 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
currentBranch="main"
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
currentBranch="main"
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -492,11 +523,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -515,11 +548,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(100);
|
||||
@@ -569,11 +604,13 @@ describe('SessionPicker', () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SessionPicker
|
||||
sessionService={mockService as never}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await wait(200);
|
||||
|
||||
@@ -4,182 +4,51 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { render, Box, Text, useApp } from 'ink';
|
||||
import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useSessionPicker } from '../hooks/useStandaloneSessionPicker.js';
|
||||
import { SessionListItemView } from './SessionListItem.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
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';
|
||||
|
||||
// Exported for testing
|
||||
export interface SessionPickerProps {
|
||||
interface StandalonePickerScreenProps {
|
||||
sessionService: SessionService;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
currentBranch?: string;
|
||||
}
|
||||
|
||||
// Prefix characters for standalone fullscreen picker
|
||||
const STANDALONE_PREFIX_CHARS = {
|
||||
selected: '› ',
|
||||
scrollUp: '↑ ',
|
||||
scrollDown: '↓ ',
|
||||
normal: ' ',
|
||||
};
|
||||
|
||||
// Exported for testing
|
||||
export function SessionPicker({
|
||||
function StandalonePickerScreen({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: SessionPickerProps): React.JSX.Element {
|
||||
currentBranch,
|
||||
}: StandalonePickerScreenProps): React.JSX.Element {
|
||||
const { exit } = useApp();
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
const itemHeight = 3;
|
||||
const maxVisibleItems = Math.max(
|
||||
1,
|
||||
Math.floor((terminalSize.height - reservedLines) / itemHeight),
|
||||
);
|
||||
|
||||
const handleExit = () => {
|
||||
setIsExiting(true);
|
||||
exit();
|
||||
};
|
||||
|
||||
const picker = useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection: true,
|
||||
onExit: handleExit,
|
||||
isActive: !isExiting,
|
||||
});
|
||||
|
||||
// Calculate content width (terminal width minus border padding)
|
||||
const contentWidth = terminalSize.width - 4;
|
||||
const promptMaxWidth = contentWidth - 4;
|
||||
|
||||
// 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}>
|
||||
{t('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">
|
||||
{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={promptMaxWidth}
|
||||
prefixChars={STANDALONE_PREFIX_CHARS}
|
||||
boldSelectedPrefix={false}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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={picker.filterByBranch}
|
||||
color={picker.filterByBranch ? theme.text.accent : undefined}
|
||||
>
|
||||
B
|
||||
</Text>
|
||||
{t(' to toggle branch') + ' · '}
|
||||
</>
|
||||
)}
|
||||
{t('to navigate · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<SessionPicker
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
onSelect(id);
|
||||
handleExit();
|
||||
}}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
handleExit();
|
||||
}}
|
||||
currentBranch={currentBranch}
|
||||
centerSelection={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,8 +74,6 @@ export async function showResumeSessionPicker(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentBranch = getGitBranch(cwd);
|
||||
|
||||
// Clear the screen before showing the picker for a clean fullscreen experience
|
||||
clearScreen();
|
||||
|
||||
@@ -220,16 +87,18 @@ export async function showResumeSessionPicker(
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<SessionPicker
|
||||
sessionService={sessionService}
|
||||
currentBranch={currentBranch}
|
||||
onSelect={(id) => {
|
||||
selectedId = id;
|
||||
}}
|
||||
onCancel={() => {
|
||||
selectedId = undefined;
|
||||
}}
|
||||
/>,
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<StandalonePickerScreen
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
selectedId = id;
|
||||
}}
|
||||
onCancel={() => {
|
||||
selectedId = undefined;
|
||||
}}
|
||||
currentBranch={getGitBranch(cwd)}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
|
||||
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'clear',
|
||||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
|
||||
@@ -5,25 +5,28 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session picker hook for dialog mode (within main app).
|
||||
* Uses useKeypress (KeypressContext) instead of useInput (ink).
|
||||
* For standalone mode, use useSessionPicker instead.
|
||||
* 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 { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
SessionService,
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
SessionListItem,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SESSION_PAGE_SIZE,
|
||||
filterSessions,
|
||||
SESSION_PAGE_SIZE,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
|
||||
export interface UseDialogSessionPickerOptions {
|
||||
export interface UseSessionPickerOptions {
|
||||
sessionService: SessionService | null;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
@@ -40,8 +43,7 @@ export interface UseDialogSessionPickerOptions {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDialogSessionPickerResult {
|
||||
// State
|
||||
export interface UseSessionPickerResult {
|
||||
selectedIndex: number;
|
||||
sessionState: SessionState;
|
||||
filteredSessions: SessionListItem[];
|
||||
@@ -51,12 +53,10 @@ export interface UseDialogSessionPickerResult {
|
||||
visibleSessions: SessionListItem[];
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
|
||||
// Actions
|
||||
loadMoreSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDialogSessionPicker({
|
||||
export function useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
@@ -64,7 +64,7 @@ export function useDialogSessionPicker({
|
||||
maxVisibleItems,
|
||||
centerSelection = false,
|
||||
isActive = true,
|
||||
}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult {
|
||||
}: UseSessionPickerOptions): UseSessionPickerResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
@@ -73,43 +73,47 @@ export function useDialogSessionPicker({
|
||||
});
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// For follow mode (non-centered)
|
||||
const [followScrollOffset, setFollowScrollOffset] = useState(0);
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
// Filter sessions
|
||||
const filteredSessions = filterSessions(
|
||||
sessionState.sessions,
|
||||
filterByBranch,
|
||||
currentBranch,
|
||||
const filteredSessions = useMemo(
|
||||
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
|
||||
[sessionState.sessions, filterByBranch, currentBranch],
|
||||
);
|
||||
|
||||
// Calculate scroll offset based on mode
|
||||
const scrollOffset = 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;
|
||||
})()
|
||||
: followScrollOffset;
|
||||
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 = filteredSessions.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxVisibleItems,
|
||||
const visibleSessions = useMemo(
|
||||
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
|
||||
[filteredSessions, maxVisibleItems, scrollOffset],
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Load initial sessions
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
// Guard: don't load if sessionService is not ready
|
||||
if (!sessionService) {
|
||||
return;
|
||||
}
|
||||
@@ -128,10 +132,10 @@ export function useDialogSessionPicker({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadInitialSessions();
|
||||
|
||||
void loadInitialSessions();
|
||||
}, [sessionService]);
|
||||
|
||||
// Load more sessions
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
|
||||
return;
|
||||
@@ -169,9 +173,8 @@ export function useDialogSessionPicker({
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Auto-load more when list is empty or near end (for centered mode)
|
||||
// Auto-load more when centered mode hits the sentinel or list is empty.
|
||||
useEffect(() => {
|
||||
// Don't auto-load during initial load or if not in centered mode
|
||||
if (
|
||||
isLoading ||
|
||||
!sessionState.hasMore ||
|
||||
@@ -182,7 +185,6 @@ export function useDialogSessionPicker({
|
||||
}
|
||||
|
||||
const sentinelVisible =
|
||||
sessionState.hasMore &&
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
|
||||
|
||||
@@ -190,27 +192,25 @@ export function useDialogSessionPicker({
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
filteredSessions.length,
|
||||
loadMoreSessions,
|
||||
sessionState.hasMore,
|
||||
scrollOffset,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
isLoading,
|
||||
loadMoreSessions,
|
||||
maxVisibleItems,
|
||||
scrollOffset,
|
||||
sessionState.hasMore,
|
||||
]);
|
||||
|
||||
// Handle keyboard input using useKeypress (KeypressContext)
|
||||
// Key handling (KeypressContext)
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const { name, sequence, ctrl } = key;
|
||||
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (name === 'escape' || (ctrl && name === 'c')) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (name === 'return') {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
@@ -219,11 +219,9 @@ export function useDialogSessionPicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation up
|
||||
if (name === 'up' || name === 'k') {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.max(0, prev - 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
if (!centerSelection && newIndex < followScrollOffset) {
|
||||
setFollowScrollOffset(newIndex);
|
||||
}
|
||||
@@ -232,7 +230,6 @@ export function useDialogSessionPicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation down
|
||||
if (name === 'down' || name === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
@@ -240,28 +237,28 @@ export function useDialogSessionPicker({
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
|
||||
if (
|
||||
!centerSelection &&
|
||||
newIndex >= followScrollOffset + maxVisibleItems
|
||||
) {
|
||||
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
|
||||
}
|
||||
// Load more if near the end
|
||||
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
|
||||
loadMoreSessions();
|
||||
|
||||
// Follow mode: load more when near the end.
|
||||
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle branch filter
|
||||
if (sequence === 'b' || sequence === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
97
packages/cli/src/ui/hooks/useSessionSelect.test.ts
Normal file
97
packages/cli/src/ui/hooks/useSessionSelect.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @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 { useSessionSelect } from './useSessionSelect.js';
|
||||
|
||||
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 { conversation: [{ role: 'user', parts: [{ text: 'hello' }] }] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation: vi.fn(() => [{ role: 'user', parts: [] }]),
|
||||
replayUiTelemetryFromConversation: vi.fn(),
|
||||
uiTelemetryService: { reset: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
describe('useSessionSelect', () => {
|
||||
it('no-ops when config is null', async () => {
|
||||
const closeResumeDialog = vi.fn();
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionSelect({
|
||||
config: null,
|
||||
closeResumeDialog,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current('session-1');
|
||||
});
|
||||
|
||||
expect(closeResumeDialog).not.toHaveBeenCalled();
|
||||
expect(startNewSession).not.toHaveBeenCalled();
|
||||
expect(historyManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(historyManager.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the dialog immediately and restores session state', async () => {
|
||||
const closeResumeDialog = vi.fn();
|
||||
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(() =>
|
||||
useSessionSelect({
|
||||
config,
|
||||
closeResumeDialog,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
const resumePromise = act(async () => {
|
||||
await result.current('session-2');
|
||||
});
|
||||
|
||||
expect(closeResumeDialog).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
64
packages/cli/src/ui/hooks/useSessionSelect.ts
Normal file
64
packages/cli/src/ui/hooks/useSessionSelect.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { 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 UseSessionSelectOptions {
|
||||
config: Config | null;
|
||||
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
|
||||
closeResumeDialog: () => void;
|
||||
startNewSession: (sessionId: string) => void;
|
||||
remount?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable callback to resume a saved session and restore UI + client state.
|
||||
*/
|
||||
export function useSessionSelect({
|
||||
config,
|
||||
closeResumeDialog,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
remount,
|
||||
}: UseSessionSelectOptions): (sessionId: string) => void {
|
||||
return useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!config) {
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session picker hook for standalone mode (fullscreen CLI picker).
|
||||
* Uses useInput (ink) instead of useKeypress (KeypressContext).
|
||||
* For dialog mode within the main app, use useDialogSessionPicker instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useInput } from 'ink';
|
||||
import type {
|
||||
SessionService,
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SESSION_PAGE_SIZE,
|
||||
filterSessions,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.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;
|
||||
/**
|
||||
* Optional callback when exiting (for standalone mode).
|
||||
*/
|
||||
onExit?: () => void;
|
||||
/**
|
||||
* Enable/disable input handling.
|
||||
*/
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UseSessionPickerResult {
|
||||
// State
|
||||
selectedIndex: number;
|
||||
sessionState: SessionState;
|
||||
filteredSessions: SessionListItem[];
|
||||
filterByBranch: boolean;
|
||||
isLoading: boolean;
|
||||
scrollOffset: number;
|
||||
visibleSessions: SessionListItem[];
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
|
||||
// Actions
|
||||
loadMoreSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection = false,
|
||||
onExit,
|
||||
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);
|
||||
|
||||
// Filter sessions
|
||||
const filteredSessions = filterSessions(
|
||||
sessionState.sessions,
|
||||
filterByBranch,
|
||||
currentBranch,
|
||||
);
|
||||
|
||||
// Calculate scroll offset based on mode
|
||||
const scrollOffset = 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;
|
||||
})()
|
||||
: followScrollOffset;
|
||||
|
||||
const visibleSessions = filteredSessions.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxVisibleItems,
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Load initial sessions
|
||||
useEffect(() => {
|
||||
// Guard: don't load if sessionService is not ready
|
||||
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);
|
||||
}
|
||||
};
|
||||
loadInitialSessions();
|
||||
}, [sessionService]);
|
||||
|
||||
// Load more sessions
|
||||
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 list is empty or near end (for centered mode)
|
||||
useEffect(() => {
|
||||
// Don't auto-load during initial load or if not in centered mode
|
||||
if (
|
||||
isLoading ||
|
||||
!sessionState.hasMore ||
|
||||
isLoadingMoreRef.current ||
|
||||
!centerSelection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinelVisible =
|
||||
sessionState.hasMore &&
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
|
||||
|
||||
if (shouldLoadMore) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
filteredSessions.length,
|
||||
loadMoreSessions,
|
||||
sessionState.hasMore,
|
||||
scrollOffset,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (key.escape || (key.ctrl && input === 'c')) {
|
||||
onCancel();
|
||||
onExit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (key.return) {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
onSelect(session.sessionId);
|
||||
onExit?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation up
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.max(0, prev - 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
if (!centerSelection && newIndex < followScrollOffset) {
|
||||
setFollowScrollOffset(newIndex);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation down
|
||||
if (key.downArrow || input === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
if (
|
||||
!centerSelection &&
|
||||
newIndex >= followScrollOffset + maxVisibleItems
|
||||
) {
|
||||
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
|
||||
}
|
||||
// Load more if near the end
|
||||
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
|
||||
loadMoreSessions();
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle branch filter
|
||||
if (input === 'b' || input === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,14 @@ 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 {
|
||||
if (text.length <= maxWidth) {
|
||||
return text;
|
||||
const firstLine = text.split(/\r?\n/, 1)[0];
|
||||
if (firstLine.length <= maxWidth) {
|
||||
return firstLine;
|
||||
}
|
||||
if (maxWidth <= 3) {
|
||||
return text.slice(0, maxWidth);
|
||||
return firstLine.slice(0, maxWidth);
|
||||
}
|
||||
return text.slice(0, maxWidth - 3) + '...';
|
||||
return firstLine.slice(0, maxWidth - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,10 +43,6 @@ export function filterSessions(
|
||||
currentBranch?: string,
|
||||
): SessionListItem[] {
|
||||
return sessions.filter((session) => {
|
||||
// Always exclude sessions with no messages
|
||||
if (session.messageCount === 0) {
|
||||
return false;
|
||||
}
|
||||
// Apply branch filter if enabled
|
||||
if (filterByBranch && currentBranch) {
|
||||
return session.gitBranch === currentBranch;
|
||||
|
||||
Reference in New Issue
Block a user