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;