diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 92833b4e..716fc392 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -20,10 +20,11 @@ These commands help you save, restore, and summarize work progress. | Command | Description | Usage Examples | | ----------- | --------------------------------------------------------- | ------------------------------------ | +| `/init` | Analyze current directory and create initial context file | `/init` | | `/summary` | Generate project summary based on conversation history | `/summary` | | `/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 ` | -| `/init` | Analyze current directory and create initial context file | `/init` | ### 1.2 Interface and Workspace Control diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 88eb1c52..4c1ce34c 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js'; +import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; export function validateDnsResolutionOrder( order: string | undefined, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d3877a8a..c9fc5801 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { summaryCommand } from '../ui/commands/summaryCommand.js'; @@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, restoreCommand(this.config), + resumeCommand, statsCommand, summaryCommand, themeCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ff16c53d..e70c0446 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; +import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => { const { stdout } = useStdout(); // Additional hooks moved from App.tsx - const { stats: sessionStats } = useSessionStats(); + const { stats: sessionStats, startNewSession } = useSessionStats(); const logger = useLogger(config.storage, sessionStats.sessionId); const branchName = useGitBranchName(config.getTargetDir()); @@ -435,6 +436,18 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); + const { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + handleResume, + } = useResumeCommand({ + config, + historyManager, + startNewSession, + remount: refreshStatic, + }); + const { showWorkspaceMigrationDialog, workspaceExtensions, @@ -488,6 +501,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, }), [ openAuthDialog, @@ -502,6 +516,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, ], ); @@ -1194,7 +1209,8 @@ export const AppContainer = (props: AppContainerProps) => { !!proQuotaRequest || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || - isApprovalModeDialogOpen; + isApprovalModeDialogOpen || + isResumeDialogOpen; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1222,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1312,6 +1329,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResume, }), [ handleThemeSelect, @@ -1453,6 +1475,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResume, ], ); diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 00000000..7fe14ab0 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts new file mode 100644 index 00000000..20592bf3 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -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 => ({ + type: 'dialog', + dialog: 'resume', + }), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f2ec2173..8bcc872f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -124,7 +124,8 @@ export interface OpenDialogActionReturn { | 'subagent_create' | 'subagent_list' | 'permissions' - | 'approval-mode'; + | 'approval-mode' + | 'resume'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d696c87a..c00c065e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -290,5 +291,16 @@ export const DialogManager = ({ ); } + if (uiState.isResumeDialogOpen) { + return ( + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx deleted file mode 100644 index 0057d700..00000000 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ /dev/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 ; - } - - return ( - - {/* Main container with single border */} - - {/* Header row */} - - - Resume Session - - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Session list with auto-scrolling */} - - {filteredSessions.length === 0 ? ( - - - {filterByBranch - ? `No sessions found for branch "${currentBranch}"` - : 'No sessions found'} - - - ) : ( - 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 ( - - {/* First line: prefix (selector or scroll indicator) + prompt text */} - - - {prefix} - - - {truncateText( - session.prompt || '(empty prompt)', - promptMaxWidth, - )} - - - - {/* Second line: metadata (aligned with prompt text) */} - - {' '} - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - - ); - }) - )} - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Footer with keyboard shortcuts */} - - - {currentBranch && ( - <> - - B - - {' to toggle branch · '} - - )} - {'↑↓ to navigate · Esc to cancel'} - - - - - ); -} - -/** - * 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 { - 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((resolve) => { - let selectedId: string | undefined; - - const { unmount, waitUntilExit } = render( - { - 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); - }); - }); -} diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx new file mode 100644 index 00000000..9729d4c6 --- /dev/null +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -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 ( + + + + {prefix} + + + {truncatedPrompt} + + + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); +} + +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 ( + + + {/* Header row */} + + + {t('Resume Session')} + + {picker.filterByBranch && currentBranch && ( + + {' '} + {t('(branch: {{branch}})', { branch: currentBranch })} + + )} + + + {/* Separator */} + + {'─'.repeat(width - 2)} + + + {/* Session list */} + + {!sessionService || picker.isLoading ? ( + + + {t('Loading sessions...')} + + + ) : picker.filteredSessions.length === 0 ? ( + + + {picker.filterByBranch + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} + + + ) : ( + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; + return ( + + ); + }) + )} + + + {/* Separator */} + + {'─'.repeat(width - 2)} + + + {/* Footer */} + + + {currentBranch && ( + + + B + + {t(' to toggle branch')} · + + )} + + {t('↑↓ to navigate · Esc to cancel')} + + + + + + ); +} diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx new file mode 100644 index 00000000..9a7c7b19 --- /dev/null +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -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 { + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + await wait(200); + + // First page should be loaded + expect(mockService.listSessions).toHaveBeenCalled(); + + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx new file mode 100644 index 00000000..bac7f23d --- /dev/null +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -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 ; + } + + return ( + { + 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 { + 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((resolve) => { + let selectedId: string | undefined; + + const { unmount, waitUntilExit } = render( + + { + selectedId = id; + }} + onCancel={() => { + selectedId = undefined; + }} + currentBranch={getGitBranch(cwd)} + /> + , + { + 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); + }); + }); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4788f7fa..2e396335 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -64,6 +64,10 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Resume session dialog + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResume: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 62e54204..d009d59e 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -60,6 +60,7 @@ export interface UIState { isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; + isResumeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6439c934..ac762904 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'clear', 'reset', 'new', + 'resume', ]); interface SlashCommandProcessorActions { @@ -66,6 +67,7 @@ interface SlashCommandProcessorActions { openModelDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; + openResumeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -417,6 +419,9 @@ export const useSlashCommandProcessor = ( case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; + case 'resume': + actions.openResumeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts new file mode 100644 index 00000000..daaedfcc --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -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 | 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 + | 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); + }); +}); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts new file mode 100644 index 00000000..8fc3d4dd --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -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; + 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, + }; +} diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts new file mode 100644 index 00000000..7d451466 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -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 `` 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; +} + +export function useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection = false, + isActive = true, +}: UseSessionPickerOptions): UseSessionPickerResult { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sessionState, setSessionState] = useState({ + 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, + }; +} diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.test.ts b/packages/cli/src/ui/utils/sessionPickerUtils.test.ts new file mode 100644 index 00000000..e561199e --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts new file mode 100644 index 00000000..74560c5b --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -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`; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d5b7f4be..1cb79905 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -741,9 +741,12 @@ export class Config { /** * Starts a new session and resets session-scoped services. */ - startNewSession(sessionId?: string): string { + startNewSession( + sessionId?: string, + sessionData?: ResumedSessionData, + ): string { this.sessionId = sessionId ?? randomUUID(); - this.sessionData = undefined; + this.sessionData = sessionData; this.chatRecordingService = this.chatRecordingEnabled ? new ChatRecordingService(this) : undefined;