From 6c77303172ae1b8457ee5d21a7b274fb4f66d220 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 18:50:01 +0100 Subject: [PATCH 01/16] Add /resume slash command to switch between previous sessions --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/commands/resumeCommand.ts | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/cli/src/ui/commands/resumeCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d3877a8a..c9fc5801 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { summaryCommand } from '../ui/commands/summaryCommand.js'; @@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, restoreCommand(this.config), + resumeCommand, statsCommand, summaryCommand, themeCommand, diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts new file mode 100644 index 00000000..96aac2c0 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + SlashCommandActionReturn, + CommandContext, +} from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; +import { showResumeSessionPicker } from '../components/ResumeSessionPicker.js'; +import { + SessionService, + buildApiHistoryFromConversation, + replayUiTelemetryFromConversation, + uiTelemetryService, +} from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; + +export const resumeCommand: SlashCommand = { + name: 'resume', + kind: CommandKind.BUILT_IN, + get description() { + return t('Resume a previous session'); + }, + action: async ( + context: CommandContext, + ): Promise => { + const { config } = context.services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not available', + }; + } + + // Show the session picker + const cwd = config.getTargetDir(); + const selectedSessionId = await showResumeSessionPicker(cwd); + + if (!selectedSessionId) { + // User cancelled + return; + } + + // Load the session data + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(selectedSessionId); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: `Could not load session: ${selectedSessionId}`, + }; + } + + // Reset and replay UI telemetry to restore metrics + uiTelemetryService.reset(); + replayUiTelemetryFromConversation(sessionData.conversation); + + // Build UI history items using existing utility + const uiHistoryWithIds = buildResumedHistoryItems(sessionData, config); + // Strip IDs for LoadHistoryActionReturn (IDs are re-assigned by loadHistory) + const uiHistory = uiHistoryWithIds.map(({ id: _id, ...rest }) => rest); + + // Build API history for the LLM client + const clientHistory = buildApiHistoryFromConversation( + sessionData.conversation, + ); + + // Update session in config and context + config.startNewSession(selectedSessionId); + if (context.session.startNewSession) { + context.session.startNewSession(selectedSessionId); + } + + return { + type: 'load_history', + history: uiHistory, + clientHistory, + }; + }, +}; From a761be80a54777e329d8821e6b528e035a4b35eb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 19:06:34 +0100 Subject: [PATCH 02/16] Filter out empty sessions --- .../src/ui/components/ResumeSessionPicker.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx index 0057d700..c520d5b9 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -72,13 +72,18 @@ function SessionPicker({ }; }, []); - // Filter sessions by current branch if filter is enabled - const filteredSessions = - filterByBranch && currentBranch - ? sessionState.sessions.filter( - (session) => session.gitBranch === currentBranch, - ) - : sessionState.sessions; + // Filter sessions: exclude empty sessions (0 messages) and optionally by branch + const filteredSessions = sessionState.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; + } + return true; + }); const hasSentinel = sessionState.hasMore; From 2de50ae436ab79c76fd3e99e27b06600a7d6f341 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 20:55:54 +0100 Subject: [PATCH 03/16] Add tests --- .../components/ResumeSessionPicker.test.tsx | 587 ++++++++++++++++++ .../src/ui/components/ResumeSessionPicker.tsx | 6 +- 2 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/components/ResumeSessionPicker.test.tsx diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx new file mode 100644 index 00000000..bd63c3ef --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx @@ -0,0 +1,587 @@ +/** + * @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 { SessionPicker } from './ResumeSessionPicker.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, + }); +}); + +// Helper to create mock sessions +function createMockSession( + overrides: Partial = {}, +): SessionListItem { + return { + sessionId: 'test-session-id', + cwd: '/test/path', + startTime: '2025-01-01T00:00:00.000Z', + mtime: Date.now(), + prompt: 'Test prompt', + gitBranch: 'main', + filePath: '/test/path/sessions/test-session-id.jsonl', + messageCount: 5, + ...overrides, + }; +} + +// Helper to create mock session service +function createMockSessionService( + sessions: SessionListItem[] = [], + hasMore = false, +) { + return { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore, + nextCursor: hasMore ? Date.now() : undefined, + } as ListSessionsResult), + loadSession: vi.fn(), + loadLastSession: vi + .fn() + .mockResolvedValue(sessions.length > 0 ? {} : undefined), + }; +} + +describe('SessionPicker', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Empty Sessions Filtering', () => { + it('should filter out sessions with 0 messages', async () => { + const sessions = [ + createMockSession({ + sessionId: 'empty-1', + messageCount: 0, + prompt: '', + }), + createMockSession({ + sessionId: 'with-messages', + messageCount: 5, + prompt: 'Hello', + }), + createMockSession({ + sessionId: 'empty-2', + messageCount: 0, + prompt: '(empty prompt)', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + // 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'); + }); + + it('should show "No sessions found" when all sessions are empty', async () => { + const sessions = [ + createMockSession({ sessionId: 'empty-1', messageCount: 0 }), + createMockSession({ sessionId: 'empty-2', messageCount: 0 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('No sessions found'); + }); + + it('should show sessions with 1 or more messages', async () => { + const sessions = [ + createMockSession({ + sessionId: 'one-msg', + messageCount: 1, + prompt: 'Single message', + }), + createMockSession({ + sessionId: 'many-msg', + messageCount: 10, + prompt: 'Many messages', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Single message'); + expect(output).toContain('Many messages'); + expect(output).toContain('1 message'); + expect(output).toContain('10 messages'); + }); + }); + + describe('Branch Filtering', () => { + it('should filter by branch when B is pressed', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + gitBranch: 'main', + prompt: 'Main branch', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + gitBranch: 'feature', + prompt: 'Feature branch', + messageCount: 1, + }), + createMockSession({ + sessionId: 's3', + gitBranch: 'main', + prompt: 'Also main', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + , + ); + + await wait(100); + + // All sessions should be visible initially + let output = lastFrame(); + expect(output).toContain('Main branch'); + expect(output).toContain('Feature branch'); + + // Press B to filter by branch + stdin.write('B'); + await wait(50); + + output = lastFrame(); + // Only main branch sessions should be visible + expect(output).toContain('Main branch'); + expect(output).toContain('Also main'); + expect(output).not.toContain('Feature branch'); + }); + + it('should combine empty session filter with branch filter', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + gitBranch: 'main', + messageCount: 0, + prompt: 'Empty main', + }), + createMockSession({ + sessionId: 's2', + gitBranch: 'main', + messageCount: 5, + prompt: 'Valid main', + }), + createMockSession({ + sessionId: 's3', + gitBranch: 'feature', + messageCount: 5, + prompt: 'Valid feature', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + , + ); + + await wait(100); + + // Press B to filter by branch + stdin.write('B'); + await wait(50); + + const output = lastFrame(); + // Should only show non-empty sessions from main branch + expect(output).toContain('Valid main'); + expect(output).not.toContain('Empty main'); + expect(output).not.toContain('Valid feature'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should navigate with arrow keys', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'First session', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + prompt: 'Second session', + messageCount: 1, + }), + createMockSession({ + sessionId: 's3', + prompt: 'Third session', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + , + ); + + await wait(100); + + // First session should be selected initially (indicated by >) + let output = lastFrame(); + expect(output).toContain('First session'); + + // Navigate down + stdin.write('\u001B[B'); // Down arrow + await wait(50); + + output = lastFrame(); + // Selection indicator should move + expect(output).toBeDefined(); + }); + + it('should navigate with vim keys (j/k)', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'First', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + prompt: 'Second', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin, unmount } = render( + , + ); + + await wait(100); + + // Navigate with j (down) + stdin.write('j'); + await wait(50); + + // Navigate with k (up) + stdin.write('k'); + await wait(50); + + unmount(); + }); + + it('should select session on Enter', async () => { + const sessions = [ + createMockSession({ + sessionId: 'selected-session', + prompt: 'Select me', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin } = render( + , + ); + + await wait(100); + + // Press Enter to select + stdin.write('\r'); + await wait(50); + + expect(onSelect).toHaveBeenCalledWith('selected-session'); + }); + + it('should cancel on Escape', async () => { + const sessions = [ + createMockSession({ sessionId: 's1', messageCount: 1 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin } = render( + , + ); + + await wait(100); + + // Press Escape to cancel + stdin.write('\u001B'); + await wait(50); + + expect(onCancel).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + }); + + describe('Display', () => { + it('should show session metadata', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'Test prompt text', + messageCount: 5, + gitBranch: 'feature-branch', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Test prompt text'); + expect(output).toContain('5 messages'); + expect(output).toContain('feature-branch'); + }); + + it('should show header and footer', async () => { + const sessions = [createMockSession({ messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + expect(output).toContain('to navigate'); + expect(output).toContain('Esc to cancel'); + }); + + it('should show branch toggle hint when currentBranch is provided', async () => { + const sessions = [createMockSession({ messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('B'); + expect(output).toContain('toggle branch'); + }); + + it('should truncate long prompts', async () => { + const longPrompt = 'A'.repeat(300); + const sessions = [ + createMockSession({ prompt: longPrompt, messageCount: 1 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + // Should contain ellipsis for truncated text + expect(output).toContain('...'); + // Should NOT contain the full untruncated prompt (300 A's in a row) + expect(output).not.toContain(longPrompt); + }); + + it('should show "(empty prompt)" for sessions without prompt text', async () => { + const sessions = [createMockSession({ prompt: '', messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('(empty prompt)'); + }); + }); + + describe('Pagination', () => { + it('should load more sessions when scrolling to bottom', async () => { + const firstPage = Array.from({ length: 5 }, (_, i) => + createMockSession({ + sessionId: `session-${i}`, + prompt: `Session ${i}`, + messageCount: 1, + mtime: Date.now() - i * 1000, + }), + ); + const secondPage = Array.from({ length: 3 }, (_, i) => + createMockSession({ + sessionId: `session-${i + 5}`, + prompt: `Session ${i + 5}`, + messageCount: 1, + mtime: Date.now() - (i + 5) * 1000, + }), + ); + + const mockService = { + listSessions: vi + .fn() + .mockResolvedValueOnce({ + items: firstPage, + hasMore: true, + nextCursor: Date.now() - 5000, + }) + .mockResolvedValueOnce({ + items: secondPage, + hasMore: false, + nextCursor: undefined, + }), + loadSession: vi.fn(), + loadLastSession: vi.fn().mockResolvedValue({}), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { unmount } = render( + , + ); + + await wait(200); + + // First page should be loaded + expect(mockService.listSessions).toHaveBeenCalled(); + + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx index c520d5b9..45acc915 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -17,7 +17,8 @@ import { formatRelativeTime } from '../utils/formatters.js'; const PAGE_SIZE = 20; -interface SessionPickerProps { +// Exported for testing +export interface SessionPickerProps { sessionService: SessionService; currentBranch?: string; onSelect: (sessionId: string) => void; @@ -33,7 +34,8 @@ function truncateText(text: string, maxWidth: number): string { return text.slice(0, maxWidth - 3) + '...'; } -function SessionPicker({ +// Exported for testing +export function SessionPicker({ sessionService, currentBranch, onSelect, From 12877ac8497809ef9647c58c4be3ed5ff69e4fc7 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 21:34:26 +0100 Subject: [PATCH 04/16] Refactor /resume command to use dialog instead of standalone Ink app --- packages/cli/src/ui/AppContainer.tsx | 71 +++- packages/cli/src/ui/commands/resumeCommand.ts | 78 +--- packages/cli/src/ui/commands/types.ts | 3 +- .../cli/src/ui/components/DialogManager.tsx | 14 + .../src/ui/components/ResumeSessionDialog.tsx | 346 ++++++++++++++++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + packages/cli/src/ui/hooks/useResumeCommand.ts | 25 ++ 9 files changed, 471 insertions(+), 75 deletions(-) create mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useResumeCommand.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ff16c53d..2ee02db4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; +import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => { const { stdout } = useStdout(); // Additional hooks moved from App.tsx - const { stats: sessionStats } = useSessionStats(); + const { stats: sessionStats, startNewSession } = useSessionStats(); const logger = useLogger(config.storage, sessionStats.sessionId); const branchName = useGitBranchName(config.getTargetDir()); @@ -435,6 +436,62 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); + const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } = + useResumeCommand(); + + // Handle resume session selection + const handleResumeSessionSelect = useCallback( + async (sessionId: string) => { + if (!config) return; + + 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) { + closeResumeDialog(); + 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); + + closeResumeDialog(); + }, + [config, closeResumeDialog, historyManager, startNewSession], + ); + const { showWorkspaceMigrationDialog, workspaceExtensions, @@ -488,6 +545,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, }), [ openAuthDialog, @@ -502,6 +560,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, ], ); @@ -1222,6 +1281,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1312,6 +1372,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1421,6 +1482,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResumeSessionSelect, }), [ handleThemeSelect, @@ -1453,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResumeSessionSelect, ], ); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 96aac2c0..20592bf3 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -4,21 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - SlashCommand, - SlashCommandActionReturn, - CommandContext, -} from './types.js'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -import { showResumeSessionPicker } from '../components/ResumeSessionPicker.js'; -import { - SessionService, - buildApiHistoryFromConversation, - replayUiTelemetryFromConversation, - uiTelemetryService, -} from '@qwen-code/qwen-code-core'; -import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; export const resumeCommand: SlashCommand = { name: 'resume', @@ -26,64 +14,8 @@ export const resumeCommand: SlashCommand = { get description() { return t('Resume a previous session'); }, - action: async ( - context: CommandContext, - ): Promise => { - const { config } = context.services; - - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Config not available', - }; - } - - // Show the session picker - const cwd = config.getTargetDir(); - const selectedSessionId = await showResumeSessionPicker(cwd); - - if (!selectedSessionId) { - // User cancelled - return; - } - - // Load the session data - const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadSession(selectedSessionId); - - if (!sessionData) { - return { - type: 'message', - messageType: 'error', - content: `Could not load session: ${selectedSessionId}`, - }; - } - - // Reset and replay UI telemetry to restore metrics - uiTelemetryService.reset(); - replayUiTelemetryFromConversation(sessionData.conversation); - - // Build UI history items using existing utility - const uiHistoryWithIds = buildResumedHistoryItems(sessionData, config); - // Strip IDs for LoadHistoryActionReturn (IDs are re-assigned by loadHistory) - const uiHistory = uiHistoryWithIds.map(({ id: _id, ...rest }) => rest); - - // Build API history for the LLM client - const clientHistory = buildApiHistoryFromConversation( - sessionData.conversation, - ); - - // Update session in config and context - config.startNewSession(selectedSessionId); - if (context.session.startNewSession) { - context.session.startNewSession(selectedSessionId); - } - - return { - type: 'load_history', - history: uiHistory, - clientHistory, - }; - }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'resume', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f2ec2173..8bcc872f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -124,7 +124,8 @@ export interface OpenDialogActionReturn { | 'subagent_create' | 'subagent_list' | 'permissions' - | 'approval-mode'; + | 'approval-mode' + | 'resume'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d696c87a..c0907400 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { ResumeSessionDialog } from './ResumeSessionDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -290,5 +291,18 @@ export const DialogManager = ({ ); } + if (uiState.isResumeDialogOpen) { + return ( + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx new file mode 100644 index 00000000..c83e80eb --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -0,0 +1,346 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { + SessionService, + type SessionListItem, + type ListSessionsResult, + getGitBranch, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { formatRelativeTime } from '../utils/formatters.js'; + +const PAGE_SIZE = 20; + +export interface ResumeSessionDialogProps { + cwd: string; + onSelect: (sessionId: string) => void; + onCancel: () => void; + availableTerminalHeight?: number; +} + +/** + * Truncates text to fit within a given width, adding ellipsis if needed. + */ +function truncateText(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 3) return text.slice(0, maxWidth); + return text.slice(0, maxWidth - 3) + '...'; +} + +export function ResumeSessionDialog({ + cwd, + onSelect, + onCancel, + availableTerminalHeight, +}: ResumeSessionDialogProps): React.JSX.Element { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sessionState, setSessionState] = useState<{ + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; + }>({ + sessions: [], + hasMore: false, + nextCursor: undefined, + }); + const [filterByBranch, setFilterByBranch] = useState(false); + const [currentBranch, setCurrentBranch] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [scrollOffset, setScrollOffset] = useState(0); + + const sessionServiceRef = useRef(null); + const isLoadingMoreRef = useRef(false); + + // Calculate visible items based on terminal height + const maxVisibleItems = availableTerminalHeight + ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) + : 5; + + // Initialize session service and load sessions + useEffect(() => { + const sessionService = new SessionService(cwd); + sessionServiceRef.current = sessionService; + + const branch = getGitBranch(cwd); + setCurrentBranch(branch); + + const loadInitialSessions = async () => { + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: PAGE_SIZE, + }); + setSessionState({ + sessions: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }); + } finally { + setIsLoading(false); + } + }; + + loadInitialSessions(); + }, [cwd]); + + // Filter sessions: exclude empty sessions (0 messages) and optionally by branch + const filteredSessions = sessionState.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; + } + return true; + }); + + // Load more sessions when scrolling near the end + const loadMoreSessions = useCallback(async () => { + if ( + !sessionState.hasMore || + isLoadingMoreRef.current || + !sessionServiceRef.current + ) { + return; + } + + isLoadingMoreRef.current = true; + try { + const result: ListSessionsResult = + await sessionServiceRef.current.listSessions({ + size: PAGE_SIZE, + cursor: sessionState.nextCursor, + }); + setSessionState((prev) => ({ + sessions: [...prev.sessions, ...result.items], + hasMore: result.hasMore, + nextCursor: result.nextCursor, + })); + } finally { + isLoadingMoreRef.current = false; + } + }, [sessionState.hasMore, sessionState.nextCursor]); + + // Handle keyboard input + useInput((input, key) => { + // Escape to cancel + if (key.escape) { + onCancel(); + return; + } + + // Enter to select + if (key.return) { + const session = filteredSessions[selectedIndex]; + if (session) { + onSelect(session.sessionId); + } + return; + } + + // Navigation + if (key.upArrow || input === 'k') { + setSelectedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + // Adjust scroll offset if needed + if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + return newIndex; + }); + return; + } + + 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 + if (newIndex >= scrollOffset + maxVisibleItems) { + setScrollOffset(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); + setSelectedIndex(0); + setScrollOffset(0); + } + return; + } + }); + + // Reset selection when filter changes + useEffect(() => { + setSelectedIndex(0); + setScrollOffset(0); + }, [filterByBranch]); + + // Get visible sessions for rendering + const visibleSessions = filteredSessions.slice( + scrollOffset, + scrollOffset + maxVisibleItems, + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = + scrollOffset + maxVisibleItems < filteredSessions.length; + + if (isLoading) { + return ( + + + Resume Session + + + Loading sessions... + + + ); + } + + return ( + + {/* Header */} + + + Resume Session + + {filterByBranch && currentBranch && ( + (branch: {currentBranch}) + )} + + + {/* Session List */} + + {filteredSessions.length === 0 ? ( + + + {filterByBranch + ? `No sessions found for branch "${currentBranch}"` + : 'No sessions found'} + + + ) : ( + visibleSessions.map((session, visibleIndex) => { + const actualIndex = scrollOffset + visibleIndex; + const isSelected = actualIndex === selectedIndex; + const isFirst = visibleIndex === 0; + const isLast = visibleIndex === visibleSessions.length - 1; + const timeAgo = formatRelativeTime(session.mtime); + const messageText = + session.messageCount === 1 + ? '1 message' + : `${session.messageCount} messages`; + + // Show scroll indicator on first/last visible items + const showUpIndicator = isFirst && showScrollUp; + const showDownIndicator = isLast && showScrollDown; + + // Determine the prefix + const prefix = isSelected + ? '> ' + : showUpIndicator + ? '^ ' + : showDownIndicator + ? 'v ' + : ' '; + + const promptText = session.prompt || '(empty prompt)'; + const truncatedPrompt = truncateText( + promptText, + (process.stdout.columns || 80) - 10, + ); + + return ( + + {/* First line: prefix + prompt text */} + + + {prefix} + + + {truncatedPrompt} + + + {/* Second line: metadata */} + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); + }) + )} + + + {/* Footer */} + + + {currentBranch && ( + <> + + B + + {' to toggle branch · '} + + )} + {'↑↓ to navigate · Enter to select · Esc to cancel'} + + + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4788f7fa..f8456430 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -64,6 +64,10 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Resume session dialog + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResumeSessionSelect: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 62e54204..d009d59e 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -60,6 +60,7 @@ export interface UIState { isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; + isResumeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6439c934..ff7b5909 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -66,6 +66,7 @@ interface SlashCommandProcessorActions { openModelDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; + openResumeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -417,6 +418,9 @@ export const useSlashCommandProcessor = ( case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; + case 'resume': + actions.openResumeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts new file mode 100644 index 00000000..a0f683bf --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export function useResumeCommand() { + const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); + + const openResumeDialog = useCallback(() => { + setIsResumeDialogOpen(true); + }, []); + + const closeResumeDialog = useCallback(() => { + setIsResumeDialogOpen(false); + }, []); + + return { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + }; +} From 0d40cf221346bd0cade39e36c9a26a6ee213fa5e Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 21:46:43 +0100 Subject: [PATCH 05/16] Refactor /resume command to use dialog instead of stand alone Ink app --- packages/cli/src/ui/AppContainer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2ee02db4..7605f0dd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1253,7 +1253,8 @@ export const AppContainer = (props: AppContainerProps) => { !!proQuotaRequest || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || - isApprovalModeDialogOpen; + isApprovalModeDialogOpen || + isResumeDialogOpen; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], From f5c868702bf2b85b94d338dde8781d7bc34b0700 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 09:20:58 +0100 Subject: [PATCH 06/16] Put shared code in new files --- .../src/ui/components/ResumeSessionDialog.tsx | 279 +++--------------- .../src/ui/components/ResumeSessionPicker.tsx | 279 +++--------------- .../cli/src/ui/components/SessionListItem.tsx | 108 +++++++ packages/cli/src/ui/hooks/useSessionPicker.ts | 275 +++++++++++++++++ .../cli/src/ui/utils/sessionPickerUtils.ts | 49 +++ 5 files changed, 512 insertions(+), 478 deletions(-) create mode 100644 packages/cli/src/ui/components/SessionListItem.tsx create mode 100644 packages/cli/src/ui/hooks/useSessionPicker.ts create mode 100644 packages/cli/src/ui/utils/sessionPickerUtils.ts diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index c83e80eb..1de989b1 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { - SessionService, - type SessionListItem, - type ListSessionsResult, - getGitBranch, -} from '@qwen-code/qwen-code-core'; +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 { formatRelativeTime } from '../utils/formatters.js'; - -const PAGE_SIZE = 20; +import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { SessionListItemView } from './SessionListItem.js'; export interface ResumeSessionDialogProps { cwd: string; @@ -24,186 +18,39 @@ export interface ResumeSessionDialogProps { availableTerminalHeight?: number; } -/** - * Truncates text to fit within a given width, adding ellipsis if needed. - */ -function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) return text; - if (maxWidth <= 3) return text.slice(0, maxWidth); - return text.slice(0, maxWidth - 3) + '...'; -} - export function ResumeSessionDialog({ cwd, onSelect, onCancel, availableTerminalHeight, }: ResumeSessionDialogProps): React.JSX.Element { - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<{ - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; - }>({ - sessions: [], - hasMore: false, - nextCursor: undefined, - }); - const [filterByBranch, setFilterByBranch] = useState(false); - const [currentBranch, setCurrentBranch] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [scrollOffset, setScrollOffset] = useState(0); - const sessionServiceRef = useRef(null); - const isLoadingMoreRef = useRef(false); + 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; - // Initialize session service and load sessions - useEffect(() => { - const sessionService = new SessionService(cwd); - sessionServiceRef.current = sessionService; - - const branch = getGitBranch(cwd); - setCurrentBranch(branch); - - const loadInitialSessions = async () => { - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: PAGE_SIZE, - }); - setSessionState({ - sessions: result.items, - hasMore: result.hasMore, - nextCursor: result.nextCursor, - }); - } finally { - setIsLoading(false); - } - }; - - loadInitialSessions(); - }, [cwd]); - - // Filter sessions: exclude empty sessions (0 messages) and optionally by branch - const filteredSessions = sessionState.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; - } - return true; + const picker = useSessionPicker({ + sessionService: sessionServiceRef.current!, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection: false, + isActive: isReady, }); - // Load more sessions when scrolling near the end - const loadMoreSessions = useCallback(async () => { - if ( - !sessionState.hasMore || - isLoadingMoreRef.current || - !sessionServiceRef.current - ) { - return; - } - - isLoadingMoreRef.current = true; - try { - const result: ListSessionsResult = - await sessionServiceRef.current.listSessions({ - size: PAGE_SIZE, - cursor: sessionState.nextCursor, - }); - setSessionState((prev) => ({ - sessions: [...prev.sessions, ...result.items], - hasMore: result.hasMore, - nextCursor: result.nextCursor, - })); - } finally { - isLoadingMoreRef.current = false; - } - }, [sessionState.hasMore, sessionState.nextCursor]); - - // Handle keyboard input - useInput((input, key) => { - // Escape to cancel - if (key.escape) { - onCancel(); - return; - } - - // Enter to select - if (key.return) { - const session = filteredSessions[selectedIndex]; - if (session) { - onSelect(session.sessionId); - } - return; - } - - // Navigation - if (key.upArrow || input === 'k') { - setSelectedIndex((prev) => { - const newIndex = Math.max(0, prev - 1); - // Adjust scroll offset if needed - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return newIndex; - }); - return; - } - - 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 - if (newIndex >= scrollOffset + maxVisibleItems) { - setScrollOffset(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); - setSelectedIndex(0); - setScrollOffset(0); - } - return; - } - }); - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - setScrollOffset(0); - }, [filterByBranch]); - - // Get visible sessions for rendering - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = - scrollOffset + maxVisibleItems < filteredSessions.length; - - if (isLoading) { + if (!isReady || picker.isLoading) { return ( Resume Session - {filterByBranch && currentBranch && ( + {picker.filterByBranch && currentBranch && ( (branch: {currentBranch}) )} {/* Session List */} - {filteredSessions.length === 0 ? ( + {picker.filteredSessions.length === 0 ? ( - {filterByBranch + {picker.filterByBranch ? `No sessions found for branch "${currentBranch}"` : 'No sessions found'} ) : ( - visibleSessions.map((session, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isFirst = visibleIndex === 0; - const isLast = visibleIndex === visibleSessions.length - 1; - const timeAgo = formatRelativeTime(session.mtime); - const messageText = - session.messageCount === 1 - ? '1 message' - : `${session.messageCount} messages`; - - // Show scroll indicator on first/last visible items - const showUpIndicator = isFirst && showScrollUp; - const showDownIndicator = isLast && showScrollDown; - - // Determine the prefix - const prefix = isSelected - ? '> ' - : showUpIndicator - ? '^ ' - : showDownIndicator - ? 'v ' - : ' '; - - const promptText = session.prompt || '(empty prompt)'; - const truncatedPrompt = truncateText( - promptText, - (process.stdout.columns || 80) - 10, - ); - + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; return ( - - {/* First line: prefix + prompt text */} - - - {prefix} - - - {truncatedPrompt} - - - {/* Second line: metadata */} - - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - + session={session} + isSelected={actualIndex === picker.selectedIndex} + isFirst={visibleIndex === 0} + isLast={visibleIndex === picker.visibleSessions.length - 1} + showScrollUp={picker.showScrollUp} + showScrollDown={picker.showScrollDown} + maxPromptWidth={(process.stdout.columns || 80) - 10} + /> ); }) )} diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx index 45acc915..3a10c883 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { render, Box, Text, useInput, useApp } from 'ink'; -import { - SessionService, - type SessionListItem, - type ListSessionsResult, - getGitBranch, -} from '@qwen-code/qwen-code-core'; +import { 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 { formatRelativeTime } from '../utils/formatters.js'; - -const PAGE_SIZE = 20; +import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { SessionListItemView } from './SessionListItem.js'; // Exported for testing export interface SessionPickerProps { @@ -25,14 +19,13 @@ export interface SessionPickerProps { onCancel: () => void; } -/** - * Truncates text to fit within a given width, adding ellipsis if needed. - */ -function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) return text; - if (maxWidth <= 3) return text.slice(0, maxWidth); - return text.slice(0, maxWidth - 3) + '...'; -} +// Prefix characters for standalone fullscreen picker +const STANDALONE_PREFIX_CHARS = { + selected: '› ', + scrollUp: '↑ ', + scrollDown: '↓ ', + normal: ' ', +}; // Exported for testing export function SessionPicker({ @@ -42,18 +35,6 @@ export function SessionPicker({ onCancel, }: SessionPickerProps): React.JSX.Element { const { exit } = useApp(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<{ - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; - }>({ - sessions: [], - hasMore: true, - nextCursor: undefined, - }); - const isLoadingMoreRef = useRef(false); - const [filterByBranch, setFilterByBranch] = useState(false); const [isExiting, setIsExiting] = useState(false); const [terminalSize, setTerminalSize] = useState({ width: process.stdout.columns || 80, @@ -74,159 +55,35 @@ export function SessionPicker({ }; }, []); - // Filter sessions: exclude empty sessions (0 messages) and optionally by branch - const filteredSessions = sessionState.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; - } - return true; - }); - - const hasSentinel = sessionState.hasMore; - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - }, [filterByBranch]); - - const loadMoreSessions = useCallback(async () => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; - isLoadingMoreRef.current = true; - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: PAGE_SIZE, - cursor: sessionState.nextCursor, - }); - - setSessionState((prev) => ({ - sessions: [...prev.sessions, ...result.items], - hasMore: result.hasMore && result.nextCursor !== undefined, - nextCursor: result.nextCursor, - })); - } finally { - isLoadingMoreRef.current = false; - } - }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); - // Calculate visible items // Reserved space: header (1), footer (1), separators (2), borders (2) const reservedLines = 6; // Each item takes 2 lines (prompt + metadata) + 1 line margin between items - // On average, this is ~3 lines per item, but the last item has no margin const itemHeight = 3; const maxVisibleItems = Math.max( 1, Math.floor((terminalSize.height - reservedLines) / itemHeight), ); - // Calculate scroll offset - const scrollOffset = (() => { - if (filteredSessions.length <= maxVisibleItems) return 0; - const halfVisible = Math.floor(maxVisibleItems / 2); - let offset = selectedIndex - halfVisible; - offset = Math.max(0, offset); - offset = Math.min(filteredSessions.length - maxVisibleItems, offset); - return offset; - })(); + const handleExit = () => { + setIsExiting(true); + exit(); + }; - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = - scrollOffset + maxVisibleItems < filteredSessions.length; - - // Sentinel (invisible) sits after the last session item; consider it visible - // once the viewport reaches the final real item. - const sentinelVisible = - hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length; - - // Load more when sentinel enters view or when filtered list is empty. - useEffect(() => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; - - const shouldLoadMore = - filteredSessions.length === 0 || - sentinelVisible || - isLoadingMoreRef.current; - - if (shouldLoadMore) { - void loadMoreSessions(); - } - }, [ - filteredSessions.length, - loadMoreSessions, - sessionState.hasMore, - sentinelVisible, - ]); - - // Handle keyboard input - useInput((input, key) => { - // Ignore input if already exiting - if (isExiting) { - return; - } - - // Escape or Ctrl+C to cancel - if (key.escape || (key.ctrl && input === 'c')) { - setIsExiting(true); - onCancel(); - exit(); - return; - } - - if (key.return) { - const session = filteredSessions[selectedIndex]; - if (session) { - setIsExiting(true); - onSelect(session.sessionId); - exit(); - } - return; - } - - if (key.upArrow || input === 'k') { - setSelectedIndex((prev) => Math.max(0, prev - 1)); - return; - } - - if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) { - return; - } - setSelectedIndex((prev) => - Math.min(filteredSessions.length - 1, prev + 1), - ); - return; - } - - if (input === 'b' || input === 'B') { - if (currentBranch) { - setFilterByBranch((prev) => !prev); - } - return; - } + const picker = useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection: true, + onExit: handleExit, + isActive: !isExiting, }); - // Filtered sessions may have changed, ensure selectedIndex is valid - useEffect(() => { - if ( - selectedIndex >= filteredSessions.length && - filteredSessions.length > 0 - ) { - setSelectedIndex(filteredSessions.length - 1); - } - }, [filteredSessions.length, selectedIndex]); - // Calculate content width (terminal width minus border padding) const contentWidth = terminalSize.width - 4; - const promptMaxWidth = contentWidth - 4; // Account for "› " prefix + const promptMaxWidth = contentWidth - 4; // Return empty while exiting to prevent visual glitches if (isExiting) { @@ -265,80 +122,30 @@ export function SessionPicker({ {/* Session list with auto-scrolling */} - {filteredSessions.length === 0 ? ( + {picker.filteredSessions.length === 0 ? ( - {filterByBranch + {picker.filterByBranch ? `No sessions found for branch "${currentBranch}"` : 'No sessions found'} ) : ( - visibleSessions.map((session, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isFirst = visibleIndex === 0; - const isLast = visibleIndex === visibleSessions.length - 1; - const timeAgo = formatRelativeTime(session.mtime); - const messageText = - session.messageCount === 1 - ? '1 message' - : `${session.messageCount} messages`; - - // Show scroll indicator on first/last visible items - const showUpIndicator = isFirst && showScrollUp; - const showDownIndicator = isLast && showScrollDown; - - // Determine the prefix: selector takes priority over scroll indicator - const prefix = isSelected - ? '› ' - : showUpIndicator - ? '↑ ' - : showDownIndicator - ? '↓ ' - : ' '; - + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; return ( - - {/* First line: prefix (selector or scroll indicator) + prompt text */} - - - {prefix} - - - {truncateText( - session.prompt || '(empty prompt)', - promptMaxWidth, - )} - - - - {/* Second line: metadata (aligned with prompt text) */} - - {' '} - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - + session={session} + isSelected={actualIndex === picker.selectedIndex} + isFirst={visibleIndex === 0} + isLast={visibleIndex === picker.visibleSessions.length - 1} + showScrollUp={picker.showScrollUp} + showScrollDown={picker.showScrollDown} + maxPromptWidth={promptMaxWidth} + prefixChars={STANDALONE_PREFIX_CHARS} + boldSelectedPrefix={false} + /> ); }) )} @@ -357,8 +164,8 @@ export function SessionPicker({ {currentBranch && ( <> B diff --git a/packages/cli/src/ui/components/SessionListItem.tsx b/packages/cli/src/ui/components/SessionListItem.tsx new file mode 100644 index 00000000..5d51b7bf --- /dev/null +++ b/packages/cli/src/ui/components/SessionListItem.tsx @@ -0,0 +1,108 @@ +/** + * @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 to use for selected, scroll up, and scroll down states. + * Defaults to ['> ', '^ ', 'v '] (dialog style). + * Use ['> ', '^ ', 'v '] for dialog or ['> ', '^ ', 'v '] for standalone. + */ + 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/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts new file mode 100644 index 00000000..65f3d377 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +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, +} from '../utils/sessionPickerUtils.js'; + +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + +export interface UseSessionPickerOptions { + sessionService: SessionService; + 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(() => { + 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 (!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.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts new file mode 100644 index 00000000..3cc47470 --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SessionListItem } from '@qwen-code/qwen-code-core'; + +/** + * Page size for loading sessions. + */ +export const SESSION_PAGE_SIZE = 20; + +/** + * Truncates text to fit within a given width, adding ellipsis if needed. + */ +export function truncateText(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 3) return text.slice(0, maxWidth); + return text.slice(0, maxWidth - 3) + '...'; +} + +/** + * Filters sessions to exclude empty ones (0 messages) and optionally by branch. + */ +export function filterSessions( + sessions: SessionListItem[], + filterByBranch: boolean, + 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; + } + return true; + }); +} + +/** + * Formats message count for display with proper pluralization. + */ +export function formatMessageCount(count: number): string { + return count === 1 ? '1 message' : `${count} messages`; +} From e76f47512cf1ac39599eb75698d217fe2b569966 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 09:53:05 +0100 Subject: [PATCH 07/16] Add guards --- .../src/ui/components/ResumeSessionDialog.tsx | 2 +- packages/cli/src/ui/hooks/useSessionPicker.ts | 19 +++++++++++++++---- .../cli/src/ui/utils/sessionPickerUtils.ts | 8 ++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index 1de989b1..a0835493 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -41,7 +41,7 @@ export function ResumeSessionDialog({ : 5; const picker = useSessionPicker({ - sessionService: sessionServiceRef.current!, + sessionService: sessionServiceRef.current, currentBranch, onSelect, onCancel, diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts index 65f3d377..f45f26fc 100644 --- a/packages/cli/src/ui/hooks/useSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -23,7 +23,7 @@ export interface SessionState { } export interface UseSessionPickerOptions { - sessionService: SessionService; + sessionService: SessionService | null; currentBranch?: string; onSelect: (sessionId: string) => void; onCancel: () => void; @@ -92,7 +92,9 @@ export function useSessionPicker({ // Calculate scroll offset based on mode const scrollOffset = centerSelection ? (() => { - if (filteredSessions.length <= maxVisibleItems) return 0; + if (filteredSessions.length <= maxVisibleItems) { + return 0; + } const halfVisible = Math.floor(maxVisibleItems / 2); let offset = selectedIndex - halfVisible; offset = Math.max(0, offset); @@ -111,6 +113,11 @@ export function useSessionPicker({ // 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({ @@ -130,7 +137,9 @@ export function useSessionPicker({ // Load more sessions const loadMoreSessions = useCallback(async () => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; + if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { + return; + } isLoadingMoreRef.current = true; try { @@ -229,7 +238,9 @@ export function useSessionPicker({ // Navigation down if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) return; + if (filteredSessions.length === 0) { + return; + } setSelectedIndex((prev) => { const newIndex = Math.min(filteredSessions.length - 1, prev + 1); diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 3cc47470..09d9f704 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -15,8 +15,12 @@ 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; - if (maxWidth <= 3) return text.slice(0, maxWidth); + if (text.length <= maxWidth) { + return text; + } + if (maxWidth <= 3) { + return text.slice(0, maxWidth); + } return text.slice(0, maxWidth - 3) + '...'; } From 1098c23b265016a8e41f7cfc528be400e3640494 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 12:13:33 +0100 Subject: [PATCH 08/16] Close dialog before async operations to prevent input capture --- packages/cli/src/ui/AppContainer.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7605f0dd..38b6a936 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -442,7 +442,12 @@ export const AppContainer = (props: AppContainerProps) => { // Handle resume session selection const handleResumeSessionSelect = useCallback( async (sessionId: string) => { - if (!config) return; + if (!config) { + return; + } + + // Close dialog immediately to prevent input capture during async operations + closeResumeDialog(); const { SessionService, @@ -459,7 +464,6 @@ export const AppContainer = (props: AppContainerProps) => { const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { - closeResumeDialog(); return; } @@ -486,8 +490,6 @@ export const AppContainer = (props: AppContainerProps) => { // Clear and load history historyManager.clearItems(); historyManager.loadHistory(uiHistoryItems); - - closeResumeDialog(); }, [config, closeResumeDialog, historyManager, startNewSession], ); From 56a62bcb2af25da7c68a5094da55c4574b470ad5 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 13:04:01 +0100 Subject: [PATCH 09/16] Fix input focus issue by using useKeypress instead of useInput for ResumeSessionDialog --- .../src/ui/components/ResumeSessionDialog.tsx | 4 +- .../src/ui/hooks/useDialogSessionPicker.ts | 287 ++++++++++++++++++ 2 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useDialogSessionPicker.ts diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index a0835493..8593eeb7 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -8,7 +8,7 @@ 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 { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js'; import { SessionListItemView } from './SessionListItem.js'; export interface ResumeSessionDialogProps { @@ -40,7 +40,7 @@ export function ResumeSessionDialog({ ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) : 5; - const picker = useSessionPicker({ + const picker = useDialogSessionPicker({ sessionService: sessionServiceRef.current, currentBranch, onSelect, diff --git a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts new file mode 100644 index 00000000..35547f4a --- /dev/null +++ b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Session picker hook for dialog mode (within main app). + * Uses useKeypress (KeypressContext) instead of useInput (ink). + * For standalone mode, use useSessionPicker instead. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { + SessionService, + SessionListItem, + ListSessionsResult, +} from '@qwen-code/qwen-code-core'; +import { + SESSION_PAGE_SIZE, + filterSessions, +} from '../utils/sessionPickerUtils.js'; +import { useKeypress } from './useKeypress.js'; + +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + +export interface UseDialogSessionPickerOptions { + sessionService: SessionService | null; + currentBranch?: string; + onSelect: (sessionId: string) => void; + onCancel: () => void; + maxVisibleItems: number; + /** + * If true, computes centered scroll offset (keeps selection near middle). + * If false, uses follow mode (scrolls when selection reaches edge). + */ + centerSelection?: boolean; + /** + * Enable/disable input handling. + */ + isActive?: boolean; +} + +export interface UseDialogSessionPickerResult { + // State + selectedIndex: number; + sessionState: SessionState; + filteredSessions: SessionListItem[]; + filterByBranch: boolean; + isLoading: boolean; + scrollOffset: number; + visibleSessions: SessionListItem[]; + showScrollUp: boolean; + showScrollDown: boolean; + + // Actions + loadMoreSessions: () => Promise; +} + +export function useDialogSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection = false, + isActive = true, +}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult { + 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 using useKeypress (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) { + onSelect(session.sessionId); + } + 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); + } + return newIndex; + }); + return; + } + + // Navigation down + if (name === 'down' || name === '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 (sequence === 'b' || sequence === 'B') { + if (currentBranch) { + setFilterByBranch((prev) => !prev); + } + return; + } + }, + { isActive }, + ); + + return { + selectedIndex, + sessionState, + filteredSessions, + filterByBranch, + isLoading, + scrollOffset, + visibleSessions, + showScrollUp, + showScrollDown, + loadMoreSessions, + }; +} From 4504c7a0ac6777c389cffe8ffd345a83a7d9478b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 13:33:44 +0100 Subject: [PATCH 10/16] Rename ResumeSessionPicker to StandaloneSessionPicker and add documentation --- docs/cli/commands.md | 10 +++ docs/features/session-resume.md | 74 +++++++++++++++++++ packages/cli/src/gemini.tsx | 2 +- ...t.tsx => StandaloneSessionPicker.test.tsx} | 2 +- ...Picker.tsx => StandaloneSessionPicker.tsx} | 2 +- ...icker.ts => useStandaloneSessionPicker.ts} | 0 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 docs/features/session-resume.md rename packages/cli/src/ui/components/{ResumeSessionPicker.test.tsx => StandaloneSessionPicker.test.tsx} (99%) rename packages/cli/src/ui/components/{ResumeSessionPicker.tsx => StandaloneSessionPicker.tsx} (98%) rename packages/cli/src/ui/hooks/{useSessionPicker.ts => useStandaloneSessionPicker.ts} (100%) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index aa056a43..09ea3e8d 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -93,6 +93,16 @@ Slash commands provide meta-level control over the CLI itself. - **Usage:** `/restore [tool_call_id]` - **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details. +- **`/resume`** + - **Description:** Resume a previous conversation session. Opens a session picker dialog to browse and select from saved sessions. + - **Usage:** `/resume` + - **Features:** + - Browse all saved sessions for the current project + - Filter sessions by git branch with the **B** key + - Sessions show first prompt, message count, and timestamp + - Navigate with arrow keys or **j/k**, select with **Enter** + - **Note:** For command-line session resumption, see `--resume` and `--continue` flags. For more details, see [Session Resume](../features/session-resume.md). + - **`/settings`** - **Description:** Open the settings editor to view and modify Qwen Code settings. - **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Qwen Code. It is equivalent to manually editing the `.qwen/settings.json` file, but with validation and guidance to prevent errors. diff --git a/docs/features/session-resume.md b/docs/features/session-resume.md new file mode 100644 index 00000000..00984f30 --- /dev/null +++ b/docs/features/session-resume.md @@ -0,0 +1,74 @@ +# Session Resume + +Qwen Code automatically saves your conversation history, allowing you to resume previous sessions at any time. + +## Overview + +Sessions are saved automatically as you work. You can resume them either from the command line when starting Qwen Code, or from within an active session using the `/resume` command. + +## How Sessions Are Stored + +Sessions are stored as JSONL files (one JSON record per line) at: + +``` +~/.qwen/tmp//chats/.jsonl +``` + +Each session captures: + +- User messages and assistant responses +- Tool calls and their results +- Metadata: timestamps, git branch, working directory, model used + +## Resuming Sessions + +### From the Command Line + +**Resume most recent session:** + +```bash +qwen --continue +``` + +**Show session picker:** + +```bash +qwen --resume +``` + +**Resume specific session by ID:** + +```bash +qwen --resume +``` + +### From Within the App + +Use the `/resume` slash command to open a session picker dialog: + +``` +/resume +``` + +### Session Picker Controls + +- **Arrow keys** or **j/k**: Navigate between sessions +- **Enter**: Select and resume the highlighted session +- **B**: Toggle branch filter (show only sessions from current git branch) +- **Escape**: Cancel and return to current session + +## Session List Display + +Each session shows: + +- First prompt text (truncated if long) +- Number of messages +- Last modified timestamp +- Git branch name (if available) + +Sessions are sorted by last modified time, with most recent first. + +## Related Features + +- [Welcome Back](./welcome-back.md) - Automatic session context restoration +- [/summary command](../cli/commands.md) - Generate project summaries for future reference diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 18f191bc..ee327c2b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js'; +import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; export function validateDnsResolutionOrder( order: string | undefined, diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx similarity index 99% rename from packages/cli/src/ui/components/ResumeSessionPicker.test.tsx rename to packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx index bd63c3ef..c6841f2f 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.test.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -6,7 +6,7 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SessionPicker } from './ResumeSessionPicker.js'; +import { SessionPicker } from './StandaloneSessionPicker.js'; import type { SessionListItem, ListSessionsResult, diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx similarity index 98% rename from packages/cli/src/ui/components/ResumeSessionPicker.tsx rename to packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 3a10c883..b519710e 100644 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -8,7 +8,7 @@ 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/useSessionPicker.js'; +import { useSessionPicker } from '../hooks/useStandaloneSessionPicker.js'; import { SessionListItemView } from './SessionListItem.js'; // Exported for testing diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts similarity index 100% rename from packages/cli/src/ui/hooks/useSessionPicker.ts rename to packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts From 7a97fcd5f130ca4319faf0378d6d78fbda55ebfb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 14:03:35 +0100 Subject: [PATCH 11/16] Add tests for /resume command and update SettingsDialog snapshots --- .../cli/src/ui/commands/resumeCommand.test.ts | 38 +++ .../components/ResumeSessionDialog.test.tsx | 303 ++++++++++++++++++ .../SettingsDialog.test.tsx.snap | 40 +-- .../cli/src/ui/hooks/useResumeCommand.test.ts | 57 ++++ 4 files changed, 418 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/ui/commands/resumeCommand.test.ts create mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.test.tsx create mode 100644 packages/cli/src/ui/hooks/useResumeCommand.test.ts diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 00000000..7fe14ab0 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('resumeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the resume dialog', async () => { + // Ensure the command has an action to test. + if (!resumeCommand.action) { + throw new Error('The resume command must have an action.'); + } + + const result = await resumeCommand.action(mockContext, ''); + + // Assert that the action returns the correct object to trigger the resume dialog. + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + }); + }); + + it('should have the correct name and description', () => { + expect(resumeCommand.name).toBe('resume'); + expect(resumeCommand.description).toBe('Resume a previous session'); + }); +}); diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx new file mode 100644 index 00000000..52330624 --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx @@ -0,0 +1,303 @@ +/** + * @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/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 7c2c04f9..fbc2244b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false* │ -│ │ │ ▼ │ │ │ │ │ @@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips true* │ -│ │ │ ▼ │ │ │ │ │ diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts new file mode 100644 index 00000000..3303b644 --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useResumeCommand } from './useResumeCommand.js'; + +describe('useResumeCommand', () => { + it('should initialize with dialog closed', () => { + const { result } = renderHook(() => useResumeCommand()); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should open the dialog when openResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + }); + + it('should close the dialog when closeResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + // Open the dialog first + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + + // Close the dialog + act(() => { + result.current.closeResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should maintain stable function references across renders', () => { + const { result, rerender } = renderHook(() => useResumeCommand()); + + const initialOpenFn = result.current.openResumeDialog; + const initialCloseFn = result.current.closeResumeDialog; + + rerender(); + + expect(result.current.openResumeDialog).toBe(initialOpenFn); + expect(result.current.closeResumeDialog).toBe(initialCloseFn); + }); +}); From 4930a24d07ed09093cf801075480e642dd59dc0b Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 14:35:40 +0100 Subject: [PATCH 12/16] Polish the PR, minor improvements --- .../src/ui/components/ResumeSessionDialog.tsx | 22 ++++++++++++------- .../cli/src/ui/components/SessionListItem.tsx | 6 ++--- .../ui/components/StandaloneSessionPicker.tsx | 13 ++++++----- .../src/ui/hooks/useDialogSessionPicker.ts | 7 +----- .../ui/hooks/useStandaloneSessionPicker.ts | 13 ++++++----- .../cli/src/ui/utils/sessionPickerUtils.ts | 9 ++++++++ 6 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx index 8593eeb7..a52f89d0 100644 --- a/packages/cli/src/ui/components/ResumeSessionDialog.tsx +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -10,6 +10,7 @@ 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; @@ -59,10 +60,10 @@ export function ResumeSessionDialog({ padding={1} > - Resume Session + {t('Resume Session')} - Loading sessions... + {t('Loading sessions...')} ); @@ -78,10 +79,13 @@ export function ResumeSessionDialog({ {/* Header */} - Resume Session + {t('Resume Session')} {picker.filterByBranch && currentBranch && ( - (branch: {currentBranch}) + + {' '} + {t('(branch: {{branch}})', { branch: currentBranch })} + )} @@ -91,8 +95,10 @@ export function ResumeSessionDialog({ {picker.filterByBranch - ? `No sessions found for branch "${currentBranch}"` - : 'No sessions found'} + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} ) : ( @@ -130,10 +136,10 @@ export function ResumeSessionDialog({ B - {' to toggle branch · '} + {t(' to toggle branch') + ' · '} )} - {'↑↓ to navigate · Enter to select · Esc to cancel'} + {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 index 5d51b7bf..7e577b4c 100644 --- a/packages/cli/src/ui/components/SessionListItem.tsx +++ b/packages/cli/src/ui/components/SessionListItem.tsx @@ -22,9 +22,9 @@ export interface SessionListItemViewProps { showScrollDown: boolean; maxPromptWidth: number; /** - * Prefix characters to use for selected, scroll up, and scroll down states. - * Defaults to ['> ', '^ ', 'v '] (dialog style). - * Use ['> ', '^ ', 'v '] for dialog or ['> ', '^ ', 'v '] for standalone. + * Prefix characters for selection indicator and scroll hints. + * Dialog style uses '> ', '^ ', 'v ' (ASCII). + * Standalone style uses special Unicode characters. */ prefixChars?: { selected: string; diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index b519710e..2f13f75c 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -10,6 +10,7 @@ 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'; // Exported for testing export interface SessionPickerProps { @@ -109,7 +110,7 @@ export function SessionPicker({ {/* Header row */} - Resume Session + {t('Resume Session')} @@ -126,8 +127,10 @@ export function SessionPicker({ {picker.filterByBranch - ? `No sessions found for branch "${currentBranch}"` - : 'No sessions found'} + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} ) : ( @@ -169,10 +172,10 @@ export function SessionPicker({ > B - {' to toggle branch · '} + {t(' to toggle branch') + ' · '} )} - {'↑↓ to navigate · Esc to cancel'} + {t('to navigate · Esc to cancel')} diff --git a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts index 35547f4a..0292f829 100644 --- a/packages/cli/src/ui/hooks/useDialogSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useDialogSessionPicker.ts @@ -19,15 +19,10 @@ import type { import { SESSION_PAGE_SIZE, filterSessions, + type SessionState, } from '../utils/sessionPickerUtils.js'; import { useKeypress } from './useKeypress.js'; -export interface SessionState { - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; -} - export interface UseDialogSessionPickerOptions { sessionService: SessionService | null; currentBranch?: string; diff --git a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts index f45f26fc..601f49ed 100644 --- a/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useStandaloneSessionPicker.ts @@ -4,6 +4,12 @@ * 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 { @@ -14,14 +20,9 @@ import type { import { SESSION_PAGE_SIZE, filterSessions, + type SessionState, } from '../utils/sessionPickerUtils.js'; -export interface SessionState { - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; -} - export interface UseSessionPickerOptions { sessionService: SessionService | null; currentBranch?: string; diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 09d9f704..89942fd8 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -6,6 +6,15 @@ import type { SessionListItem } from '@qwen-code/qwen-code-core'; +/** + * State for managing loaded sessions in the session picker. + */ +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + /** * Page size for loading sessions. */ From 2837aa6b7ce24b1c8b5cfca6cdceb13fd89a27e8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 19:54:55 +0800 Subject: [PATCH 13/16] 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; From fb8412a96a04e0bb742c8af6c0ea032ae2d0e20c Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 20:03:49 +0800 Subject: [PATCH 14/16] code refactor --- packages/cli/src/ui/AppContainer.tsx | 16 +-- .../cli/src/ui/components/DialogManager.tsx | 2 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 2 +- .../cli/src/ui/hooks/useResumeCommand.test.ts | 133 +++++++++++++++++- packages/cli/src/ui/hooks/useResumeCommand.ts | 59 +++++++- .../cli/src/ui/hooks/useSessionSelect.test.ts | 97 ------------- packages/cli/src/ui/hooks/useSessionSelect.ts | 64 --------- 7 files changed, 200 insertions(+), 173 deletions(-) delete mode 100644 packages/cli/src/ui/hooks/useSessionSelect.test.ts delete mode 100644 packages/cli/src/ui/hooks/useSessionSelect.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5da98f0a..e70c0446 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -99,7 +99,6 @@ 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; @@ -437,13 +436,14 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); - const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } = - useResumeCommand(); - - const handleResumeSessionSelect = useSessionSelect({ + const { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + handleResume, + } = useResumeCommand({ config, historyManager, - closeResumeDialog, startNewSession, remount: refreshStatic, }); @@ -1442,7 +1442,7 @@ export const AppContainer = (props: AppContainerProps) => { // Resume session dialog openResumeDialog, closeResumeDialog, - handleResumeSessionSelect, + handleResume, }), [ handleThemeSelect, @@ -1478,7 +1478,7 @@ export const AppContainer = (props: AppContainerProps) => { // Resume session dialog openResumeDialog, closeResumeDialog, - handleResumeSessionSelect, + handleResume, ], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d79014e8..ce2c93bb 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -296,7 +296,7 @@ export const DialogManager = ({ ); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f8456430..2e396335 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -67,7 +67,7 @@ export interface UIActions { // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; - handleResumeSessionSelect: (sessionId: string) => void; + handleResume: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 3303b644..a0441cd5 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -5,9 +5,59 @@ */ import { act, renderHook } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { useResumeCommand } from './useResumeCommand.js'; +const resumeMocks = vi.hoisted(() => { + let resolveLoadSession: + | ((value: { conversation: unknown } | undefined) => void) + | undefined; + let pendingLoadSession: + | Promise<{ conversation: unknown } | undefined> + | undefined; + + return { + createPendingLoadSession() { + pendingLoadSession = new Promise((resolve) => { + resolveLoadSession = resolve; + }); + return pendingLoadSession; + }, + resolvePendingLoadSession(value: { conversation: unknown } | undefined) { + resolveLoadSession?.(value); + }, + getPendingLoadSession() { + return pendingLoadSession; + }, + reset() { + resolveLoadSession = undefined; + pendingLoadSession = undefined; + }, + }; +}); + +vi.mock('../utils/resumeHistoryUtils.js', () => ({ + buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadSession(_sessionId: string) { + return ( + resumeMocks.getPendingLoadSession() ?? + Promise.resolve({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }) + ); + } + } + + return { + SessionService, + }; +}); + describe('useResumeCommand', () => { it('should initialize with dialog closed', () => { const { result } = renderHook(() => useResumeCommand()); @@ -48,10 +98,91 @@ describe('useResumeCommand', () => { const initialOpenFn = result.current.openResumeDialog; const initialCloseFn = result.current.closeResumeDialog; + const initialHandleResume = result.current.handleResume; rerender(); expect(result.current.openResumeDialog).toBe(initialOpenFn); expect(result.current.closeResumeDialog).toBe(initialCloseFn); + expect(result.current.handleResume).toBe(initialHandleResume); + }); + + it('handleResume no-ops when config is null', async () => { + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + + const { result } = renderHook(() => + useResumeCommand({ + config: null, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('session-1'); + }); + + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + }); + + it('handleResume closes the dialog immediately and restores session state', async () => { + resumeMocks.reset(); + resumeMocks.createPendingLoadSession(); + + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi.fn(), + }; + + const config = { + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useResumeCommand({ + config, + historyManager, + startNewSession, + }), + ); + + // Open first so we can verify the dialog closes immediately. + act(() => { + result.current.openResumeDialog(); + }); + expect(result.current.isResumeDialogOpen).toBe(true); + + const resumePromise = act(async () => { + // Intentionally do not resolve loadSession yet. + await result.current.handleResume('session-2'); + }); + + // After the first flush, the dialog should already be closed even though + // the session load is still pending. + await act(async () => {}); + expect(result.current.isResumeDialogOpen).toBe(false); + + // Now finish the async load and let the handler complete. + resumeMocks.resolvePendingLoadSession({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }); + await resumePromise; + + expect(config.startNewSession).toHaveBeenCalledWith( + 'session-2', + expect.objectContaining({ + conversation: expect.anything(), + }), + ); + expect(startNewSession).toHaveBeenCalledWith('session-2'); + expect(geminiClient.initialize).toHaveBeenCalledTimes(1); + expect(historyManager.clearItems).toHaveBeenCalledTimes(1); + expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index a0f683bf..8fc3d4dd 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -5,8 +5,27 @@ */ import { useState, useCallback } from 'react'; +import { SessionService, type Config } from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -export function useResumeCommand() { +export interface UseResumeCommandOptions { + config: Config | null; + historyManager: Pick; + startNewSession: (sessionId: string) => void; + remount?: () => void; +} + +export interface UseResumeCommandResult { + isResumeDialogOpen: boolean; + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResume: (sessionId: string) => void; +} + +export function useResumeCommand( + options?: UseResumeCommandOptions, +): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); const openResumeDialog = useCallback(() => { @@ -17,9 +36,47 @@ export function useResumeCommand() { setIsResumeDialogOpen(false); }, []); + const { config, historyManager, startNewSession, remount } = options ?? {}; + + const handleResume = useCallback( + async (sessionId: string) => { + if (!config || !historyManager || !startNewSession) { + return; + } + + // Close dialog immediately to prevent input capture during async operations. + closeResumeDialog(); + + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + return; + } + + // Start new session in UI context. + startNewSession(sessionId); + + // Reset UI history. + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + historyManager.clearItems(); + historyManager.loadHistory(uiHistoryItems); + + // Update session history core. + config.startNewSession(sessionId, sessionData); + await config.getGeminiClient()?.initialize?.(); + + // Refresh terminal UI. + remount?.(); + }, + [closeResumeDialog, config, historyManager, startNewSession, remount], + ); + return { isResumeDialogOpen, openResumeDialog, closeResumeDialog, + handleResume, }; } diff --git a/packages/cli/src/ui/hooks/useSessionSelect.test.ts b/packages/cli/src/ui/hooks/useSessionSelect.test.ts deleted file mode 100644 index 780636ac..00000000 --- a/packages/cli/src/ui/hooks/useSessionSelect.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @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 deleted file mode 100644 index 17ef6879..00000000 --- a/packages/cli/src/ui/hooks/useSessionSelect.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @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], - ); -} From 9267677d383f23e593be305bc3057cf4152decb8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 20:08:43 +0800 Subject: [PATCH 15/16] fix failed test --- .../cli/src/ui/hooks/useResumeCommand.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index a0441cd5..daaedfcc 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -158,21 +158,23 @@ describe('useResumeCommand', () => { }); expect(result.current.isResumeDialogOpen).toBe(true); - const resumePromise = act(async () => { - // Intentionally do not resolve loadSession yet. - await result.current.handleResume('session-2'); + let resumePromise: Promise | undefined; + act(() => { + // Start resume but do not await it yet — we want to assert the dialog + // closes immediately before the async session load completes. + resumePromise = result.current.handleResume('session-2') as unknown as + | Promise + | undefined; }); - - // After the first flush, the dialog should already be closed even though - // the session load is still pending. - await act(async () => {}); expect(result.current.isResumeDialogOpen).toBe(false); // Now finish the async load and let the handler complete. resumeMocks.resolvePendingLoadSession({ conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], }); - await resumePromise; + await act(async () => { + await resumePromise; + }); expect(config.startNewSession).toHaveBeenCalledWith( 'session-2', From bf52c6db0f845479286e9930cb6e8110b14a4c58 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 16 Dec 2025 20:36:24 +0800 Subject: [PATCH 16/16] fix review comments --- .../cli/src/ui/components/DialogManager.tsx | 4 +- .../cli/src/ui/components/SessionPicker.tsx | 40 ++++--------------- .../cli/src/ui/utils/sessionPickerUtils.ts | 2 +- 3 files changed, 11 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index ce2c93bb..c00c065e 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, getGitBranch } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; @@ -295,7 +295,7 @@ export const DialogManager = ({ return ( diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx index 767a1353..9729d4c6 100644 --- a/packages/cli/src/ui/components/SessionPicker.tsx +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -5,7 +5,6 @@ */ import { Box, Text } from 'ink'; -import { useEffect, useState } from 'react'; import type { SessionListItem as SessionData, SessionService, @@ -17,6 +16,7 @@ import { formatMessageCount, truncateText, } from '../utils/sessionPickerUtils.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { t } from '../../i18n/index.js'; export interface SessionPickerProps { @@ -125,27 +125,10 @@ export function SessionPicker(props: SessionPickerProps) { 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); - }; - }, []); + const { columns: width, rows: height } = useTerminalSize(); + // Calculate box width (width + 6 for border padding) + const boxWidth = width + 6; // Calculate visible items (same heuristic as before) // Reserved space: header (1), footer (1), separators (2), borders (2) const reservedLines = 6; @@ -153,7 +136,7 @@ export function SessionPicker(props: SessionPickerProps) { const itemHeight = 3; const maxVisibleItems = Math.max( 1, - Math.floor((terminalSize.height - reservedLines) / itemHeight), + Math.floor((height - reservedLines) / itemHeight), ); const picker = useSessionPicker({ @@ -166,17 +149,10 @@ export function SessionPicker(props: SessionPickerProps) { 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 ( @@ -184,7 +160,7 @@ export function SessionPicker(props: SessionPickerProps) { flexDirection="column" borderStyle="round" borderColor={theme.border.default} - width={width} + width={boxWidth} height={height - 1} overflow="hidden" > @@ -236,7 +212,7 @@ export function SessionPicker(props: SessionPickerProps) { isLast={visibleIndex === picker.visibleSessions.length - 1} showScrollUp={picker.showScrollUp} showScrollDown={picker.showScrollDown} - maxPromptWidth={promptMaxWidth} + maxPromptWidth={width} prefixChars={PREFIX_CHARS} boldSelectedPrefix={false} /> diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts index 3b8ab118..74560c5b 100644 --- a/packages/cli/src/ui/utils/sessionPickerUtils.ts +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -35,7 +35,7 @@ export function truncateText(text: string, maxWidth: number): string { } /** - * Filters sessions to exclude empty ones (0 messages) and optionally by branch. + * Filters sessions optionally by branch. */ export function filterSessions( sessions: SessionListItem[],