From 2837aa6b7ce24b1c8b5cfca6cdceb13fd89a27e8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 19:54:55 +0800 Subject: [PATCH] rework /resume slash command --- packages/cli/src/ui/AppContainer.tsx | 62 +--- .../cli/src/ui/components/DialogManager.tsx | 12 +- .../components/ResumeSessionDialog.test.tsx | 303 ------------------ .../src/ui/components/ResumeSessionDialog.tsx | 147 --------- .../cli/src/ui/components/SessionListItem.tsx | 108 ------- .../cli/src/ui/components/SessionPicker.tsx | 275 ++++++++++++++++ .../StandaloneSessionPicker.test.tsx | 217 +++++++------ .../ui/components/StandaloneSessionPicker.tsx | 201 ++---------- .../cli/src/ui/hooks/slashCommandProcessor.ts | 1 + ...ogSessionPicker.ts => useSessionPicker.ts} | 117 ++++--- .../cli/src/ui/hooks/useSessionSelect.test.ts | 97 ++++++ packages/cli/src/ui/hooks/useSessionSelect.ts | 64 ++++ .../ui/hooks/useStandaloneSessionPicker.ts | 287 ----------------- .../src/ui/utils/sessionPickerUtils.test.ts | 45 +++ .../cli/src/ui/utils/sessionPickerUtils.ts | 13 +- packages/core/src/config/config.ts | 7 +- 16 files changed, 724 insertions(+), 1232 deletions(-) delete mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.test.tsx delete mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.tsx delete mode 100644 packages/cli/src/ui/components/SessionListItem.tsx create mode 100644 packages/cli/src/ui/components/SessionPicker.tsx rename packages/cli/src/ui/hooks/{useDialogSessionPicker.ts => useSessionPicker.ts} (73%) create mode 100644 packages/cli/src/ui/hooks/useSessionSelect.test.ts create mode 100644 packages/cli/src/ui/hooks/useSessionSelect.ts delete mode 100644 packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts create mode 100644 packages/cli/src/ui/utils/sessionPickerUtils.test.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 38b6a936..5da98f0a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c0907400..d79014e8 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -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 ( - ); } diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx deleted file mode 100644 index 52330624..00000000 --- a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx +++ /dev/null @@ -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 { - 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( - - - , - ); - - 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( - - - , - ); - - 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( - - - , - ); - - 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( - - - , - ); - - 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( - - - , - ); - - 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( - - - , - ); - - 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( - - - , - ); - - await wait(100); - - const output = lastFrame(); - expect(output).toContain('Resume Session'); - }); - }); -}); diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx deleted file mode 100644 index a52f89d0..00000000 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ /dev/null @@ -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(null); - const [currentBranch, setCurrentBranch] = useState(); - 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 ( - - - {t('Resume Session')} - - - {t('Loading sessions...')} - - - ); - } - - return ( - - {/* Header */} - - - {t('Resume Session')} - - {picker.filterByBranch && currentBranch && ( - - {' '} - {t('(branch: {{branch}})', { branch: currentBranch })} - - )} - - - {/* Session List */} - - {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 ( - - ); - }) - )} - - - {/* Footer */} - - - {currentBranch && ( - <> - - B - - {t(' to toggle branch') + ' · '} - - )} - {t('to navigate · Enter to select · Esc to cancel')} - - - - ); -} diff --git a/packages/cli/src/ui/components/SessionListItem.tsx b/packages/cli/src/ui/components/SessionListItem.tsx deleted file mode 100644 index 7e577b4c..00000000 --- a/packages/cli/src/ui/components/SessionListItem.tsx +++ /dev/null @@ -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 ( - - {/* First line: prefix + prompt text */} - - - {prefix} - - - {truncatedPrompt} - - - {/* Second line: metadata */} - - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - - ); -} diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx new file mode 100644 index 00000000..767a1353 --- /dev/null +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -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 ( + + + + {prefix} + + + {truncatedPrompt} + + + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); +} + +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 ( + + + {/* 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 index c6841f2f..9a7c7b19 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -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( - , + + + , ); 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( - , + + + , ); 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( - , + + + , ); await wait(100); @@ -194,12 +207,14 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame, stdin } = render( - , + + + , ); await wait(100); @@ -246,12 +261,14 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame, stdin } = render( - , + + + , ); 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( - , + + + , ); await wait(100); @@ -332,11 +351,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { stdin, unmount } = render( - , + + + , ); await wait(100); @@ -365,11 +386,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { stdin } = render( - , + + + , ); await wait(100); @@ -390,11 +413,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { stdin } = render( - , + + + , ); await wait(100); @@ -423,11 +448,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -445,18 +472,20 @@ describe('SessionPicker', () => { 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('↑↓ to navigate'); expect(output).toContain('Esc to cancel'); }); @@ -467,12 +496,14 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -492,11 +523,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -515,11 +548,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { lastFrame } = render( - , + + + , ); await wait(100); @@ -569,11 +604,13 @@ describe('SessionPicker', () => { const onCancel = vi.fn(); const { unmount } = render( - , + + + , ); await wait(200); diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 2f13f75c..bac7f23d 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -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 ; } return ( - - {/* Main container with single border */} - - {/* Header row */} - - - {t('Resume Session')} - - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Session list with auto-scrolling */} - - {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 line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Footer with keyboard shortcuts */} - - - {currentBranch && ( - <> - - B - - {t(' to toggle branch') + ' · '} - - )} - {t('to navigate · Esc to cancel')} - - - - + { + 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( - { - selectedId = id; - }} - onCancel={() => { - selectedId = undefined; - }} - />, + + { + selectedId = id; + }} + onCancel={() => { + selectedId = undefined; + }} + currentBranch={getGitBranch(cwd)} + /> + , { exitOnCtrlC: false, }, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ff7b5909..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 { diff --git a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts similarity index 73% rename from packages/cli/src/ui/hooks/useDialogSessionPicker.ts rename to packages/cli/src/ui/hooks/useSessionPicker.ts index 0292f829..7d451466 100644 --- a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -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 `` 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; } -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({ 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 }, diff --git a/packages/cli/src/ui/hooks/useSessionSelect.test.ts b/packages/cli/src/ui/hooks/useSessionSelect.test.ts new file mode 100644 index 00000000..780636ac --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionSelect.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionSelect.ts b/packages/cli/src/ui/hooks/useSessionSelect.ts new file mode 100644 index 00000000..17ef6879 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionSelect.ts @@ -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; + 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], + ); +} diff --git a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts deleted file mode 100644 index 601f49ed..00000000 --- a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts +++ /dev/null @@ -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; -} - -export function useSessionPicker({ - sessionService, - currentBranch, - onSelect, - onCancel, - maxVisibleItems, - centerSelection = false, - onExit, - 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); - - // 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, - }; -} 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 index 89942fd8..3b8ab118 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -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; 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;