rework /resume slash command

This commit is contained in:
tanzhenxin 2025-12-16 19:54:55 +08:00
parent 9942b2b877
commit 2837aa6b7c
16 changed files with 724 additions and 1232 deletions

View file

@ -28,7 +28,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { AuthState } from '../types.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { AuthType, getGitBranch } from '@qwen-code/qwen-code-core';
import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@ -36,7 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { ResumeSessionDialog } from './ResumeSessionDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@ -293,13 +293,11 @@ export const DialogManager = ({
if (uiState.isResumeDialogOpen) {
return (
<ResumeSessionDialog
cwd={config.getTargetDir()}
<SessionPicker
sessionService={config.getSessionService()}
currentBranch={getGitBranch(config.getTargetDir())}
onSelect={uiActions.handleResumeSessionSelect}
onCancel={uiActions.closeResumeDialog}
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
/>
);
}

View file

@ -1,303 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ResumeSessionDialog } from './ResumeSessionDialog.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import type {
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
// Mock terminal size
const mockTerminalSize = { columns: 80, rows: 24 };
beforeEach(() => {
Object.defineProperty(process.stdout, 'columns', {
value: mockTerminalSize.columns,
configurable: true,
});
Object.defineProperty(process.stdout, 'rows', {
value: mockTerminalSize.rows,
configurable: true,
});
});
// Mock SessionService and getGitBranch
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen-code/qwen-code-core');
return {
...actual,
SessionService: vi.fn().mockImplementation(() => mockSessionService),
getGitBranch: vi.fn().mockReturnValue('main'),
};
});
// Helper to create mock sessions
function createMockSession(
overrides: Partial<SessionListItem> = {},
): SessionListItem {
return {
sessionId: 'test-session-id',
cwd: '/test/path',
startTime: '2025-01-01T00:00:00.000Z',
mtime: Date.now(),
prompt: 'Test prompt',
gitBranch: 'main',
filePath: '/test/path/sessions/test-session-id.jsonl',
messageCount: 5,
...overrides,
};
}
// Default mock session service
let mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: [],
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
describe('ResumeSessionDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('should show loading state initially', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
const output = lastFrame();
expect(output).toContain('Resume Session');
expect(output).toContain('Loading sessions...');
});
});
describe('Empty State', () => {
it('should show "No sessions found" when there are no sessions', async () => {
mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: [],
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('No sessions found');
});
});
describe('Session Display', () => {
it('should display sessions after loading', async () => {
const sessions = [
createMockSession({
sessionId: 'session-1',
prompt: 'First session prompt',
messageCount: 10,
}),
createMockSession({
sessionId: 'session-2',
prompt: 'Second session prompt',
messageCount: 5,
}),
];
mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: sessions,
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('First session prompt');
});
it('should filter out empty sessions', async () => {
const sessions = [
createMockSession({
sessionId: 'empty-session',
prompt: '',
messageCount: 0,
}),
createMockSession({
sessionId: 'valid-session',
prompt: 'Valid prompt',
messageCount: 5,
}),
];
mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: sessions,
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Valid prompt');
// Empty session should be filtered out
expect(output).not.toContain('empty-session');
});
});
describe('Footer', () => {
it('should show navigation instructions in footer', async () => {
mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: [createMockSession()],
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('to navigate');
expect(output).toContain('Enter to select');
expect(output).toContain('Esc to cancel');
});
it('should show branch toggle hint when currentBranch is available', async () => {
mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: [createMockSession()],
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
// Should show B key hint since getGitBranch is mocked to return 'main'
expect(output).toContain('B');
expect(output).toContain('toggle branch');
});
});
describe('Terminal Height', () => {
it('should accept availableTerminalHeight prop', async () => {
mockSessionService = {
listSessions: vi.fn().mockResolvedValue({
items: [createMockSession()],
hasMore: false,
nextCursor: undefined,
} as ListSessionsResult),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
// Should not throw with availableTerminalHeight prop
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ResumeSessionDialog
cwd="/test/path"
onSelect={onSelect}
onCancel={onCancel}
availableTerminalHeight={20}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Resume Session');
});
});
});

View file

@ -1,147 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js';
import { t } from '../../i18n/index.js';
export interface ResumeSessionDialogProps {
cwd: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
availableTerminalHeight?: number;
}
export function ResumeSessionDialog({
cwd,
onSelect,
onCancel,
availableTerminalHeight,
}: ResumeSessionDialogProps): React.JSX.Element {
const sessionServiceRef = useRef<SessionService | null>(null);
const [currentBranch, setCurrentBranch] = useState<string | undefined>();
const [isReady, setIsReady] = useState(false);
// Initialize session service
useEffect(() => {
sessionServiceRef.current = new SessionService(cwd);
setCurrentBranch(getGitBranch(cwd));
setIsReady(true);
}, [cwd]);
// Calculate visible items based on terminal height
const maxVisibleItems = availableTerminalHeight
? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3))
: 5;
const picker = useDialogSessionPicker({
sessionService: sessionServiceRef.current,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection: false,
isActive: isReady,
});
if (!isReady || picker.isLoading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
padding={1}
>
<Text color={theme.text.primary} bold>
{t('Resume Session')}
</Text>
<Box paddingY={1}>
<Text color={theme.text.secondary}>{t('Loading sessions...')}</Text>
</Box>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
padding={1}
>
{/* Header */}
<Box marginBottom={1}>
<Text color={theme.text.primary} bold>
{t('Resume Session')}
</Text>
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}>
{' '}
{t('(branch: {{branch}})', { branch: currentBranch })}
</Text>
)}
</Box>
{/* Session List */}
<Box flexDirection="column" paddingX={1}>
{picker.filteredSessions.length === 0 ? (
<Box paddingY={1}>
<Text color={theme.text.secondary}>
{picker.filterByBranch
? t('No sessions found for branch "{{branch}}"', {
branch: currentBranch ?? '',
})
: t('No sessions found')}
</Text>
</Box>
) : (
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<SessionListItemView
key={session.sessionId}
session={session}
isSelected={actualIndex === picker.selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={(process.stdout.columns || 80) - 10}
/>
);
})
)}
</Box>
{/* Footer */}
<Box
marginTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
paddingTop={1}
>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text color={theme.text.accent} bold>
B
</Text>
{t(' to toggle branch') + ' · '}
</>
)}
{t('to navigate · Enter to select · Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -1,108 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type { SessionListItem as SessionData } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
import {
truncateText,
formatMessageCount,
} from '../utils/sessionPickerUtils.js';
export interface SessionListItemViewProps {
session: SessionData;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
showScrollUp: boolean;
showScrollDown: boolean;
maxPromptWidth: number;
/**
* Prefix characters for selection indicator and scroll hints.
* Dialog style uses '> ', '^ ', 'v ' (ASCII).
* Standalone style uses special Unicode characters.
*/
prefixChars?: {
selected: string;
scrollUp: string;
scrollDown: string;
normal: string;
};
/**
* Whether to bold the prefix when selected.
*/
boldSelectedPrefix?: boolean;
}
const DEFAULT_PREFIX_CHARS = {
selected: '> ',
scrollUp: '^ ',
scrollDown: 'v ',
normal: ' ',
};
export function SessionListItemView({
session,
isSelected,
isFirst,
isLast,
showScrollUp,
showScrollDown,
maxPromptWidth,
prefixChars = DEFAULT_PREFIX_CHARS,
boldSelectedPrefix = true,
}: SessionListItemViewProps): React.JSX.Element {
const timeAgo = formatRelativeTime(session.mtime);
const messageText = formatMessageCount(session.messageCount);
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
const prefix = isSelected
? prefixChars.selected
: showUpIndicator
? prefixChars.scrollUp
: showDownIndicator
? prefixChars.scrollDown
: prefixChars.normal;
const promptText = session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
{/* First line: prefix + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected && boldSelectedPrefix}
>
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{truncatedPrompt}
</Text>
</Box>
{/* Second line: metadata */}
<Box paddingLeft={2}>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,275 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useEffect, useState } from 'react';
import type {
SessionListItem as SessionData,
SessionService,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { useSessionPicker } from '../hooks/useSessionPicker.js';
import { formatRelativeTime } from '../utils/formatters.js';
import {
formatMessageCount,
truncateText,
} from '../utils/sessionPickerUtils.js';
import { t } from '../../i18n/index.js';
export interface SessionPickerProps {
sessionService: SessionService | null;
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
/**
* Scroll mode. When true, keep selection centered (fullscreen-style).
* Defaults to true so dialog + standalone behave identically.
*/
centerSelection?: boolean;
}
const PREFIX_CHARS = {
selected: ' ',
scrollUp: '↑ ',
scrollDown: '↓ ',
normal: ' ',
};
interface SessionListItemViewProps {
session: SessionData;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
showScrollUp: boolean;
showScrollDown: boolean;
maxPromptWidth: number;
prefixChars?: {
selected: string;
scrollUp: string;
scrollDown: string;
normal: string;
};
boldSelectedPrefix?: boolean;
}
function SessionListItemView({
session,
isSelected,
isFirst,
isLast,
showScrollUp,
showScrollDown,
maxPromptWidth,
prefixChars = PREFIX_CHARS,
boldSelectedPrefix = true,
}: SessionListItemViewProps): React.JSX.Element {
const timeAgo = formatRelativeTime(session.mtime);
const messageText = formatMessageCount(session.messageCount);
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
const prefix = isSelected
? prefixChars.selected
: showUpIndicator
? prefixChars.scrollUp
: showDownIndicator
? prefixChars.scrollDown
: prefixChars.normal;
const promptText = session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected && boldSelectedPrefix}
>
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{truncatedPrompt}
</Text>
</Box>
<Box paddingLeft={2}>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
}
export function SessionPicker(props: SessionPickerProps) {
const {
sessionService,
onSelect,
onCancel,
currentBranch,
centerSelection = true,
} = props;
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Keep fullscreen picker responsive to terminal resize.
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
// `stdout` emits "resize" when TTY size changes.
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Calculate visible items (same heuristic as before)
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
const picker = useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection,
isActive: true,
});
const width = terminalSize.width;
const height = terminalSize.height;
// Calculate content width (terminal width minus border padding)
const contentWidth = width - 4;
const promptMaxWidth = contentWidth - 4;
return (
<Box
flexDirection="column"
width={width}
height={height - 1}
overflow="hidden"
>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={width}
height={height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Resume Session')}
</Text>
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}>
{' '}
{t('(branch: {{branch}})', { branch: currentBranch })}
</Text>
)}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
</Box>
{/* Session list */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{!sessionService || picker.isLoading ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{t('Loading sessions...')}
</Text>
</Box>
) : picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{picker.filterByBranch
? t('No sessions found for branch "{{branch}}"', {
branch: currentBranch ?? '',
})
: t('No sessions found')}
</Text>
</Box>
) : (
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<SessionListItemView
key={session.sessionId}
session={session}
isSelected={actualIndex === picker.selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={promptMaxWidth}
prefixChars={PREFIX_CHARS}
boldSelectedPrefix={false}
/>
);
})
)}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
</Box>
{/* Footer */}
<Box paddingX={1}>
<Box flexDirection="row">
{currentBranch && (
<Text color={theme.text.secondary}>
<Text
bold={picker.filterByBranch}
color={picker.filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{t(' to toggle branch')} ·
</Text>
)}
<Text color={theme.text.secondary}>
{t('↑↓ to navigate · Esc to cancel')}
</Text>
</Box>
</Box>
</Box>
</Box>
);
}

View file

@ -6,12 +6,21 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SessionPicker } from './StandaloneSessionPicker.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
import type {
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen-code/qwen-code-core');
return {
...actual,
getGitBranch: vi.fn().mockReturnValue('main'),
};
});
// Mock terminal size
const mockTerminalSize = { columns: 80, rows: 24 };
@ -68,8 +77,8 @@ describe('SessionPicker', () => {
vi.clearAllMocks();
});
describe('Empty Sessions Filtering', () => {
it('should filter out sessions with 0 messages', async () => {
describe('Empty Sessions', () => {
it('should show sessions with 0 messages', async () => {
const sessions = [
createMockSession({
sessionId: 'empty-1',
@ -92,24 +101,24 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
// Should show the session with messages
expect(output).toContain('Hello');
// Should NOT show empty sessions
expect(output).not.toContain('empty-1');
expect(output).not.toContain('empty-2');
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
expect(output).toContain('0 messages');
});
it('should show "No sessions found" when all sessions are empty', async () => {
it('should show sessions even when all sessions are empty', async () => {
const sessions = [
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
@ -119,17 +128,19 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('No sessions found');
expect(output).toContain('0 messages');
});
it('should show sessions with 1 or more messages', async () => {
@ -150,11 +161,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -194,12 +207,14 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<SessionPicker
sessionService={mockService as never}
currentBranch="main"
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
@ -246,12 +261,14 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<SessionPicker
sessionService={mockService as never}
currentBranch="main"
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
@ -261,9 +278,9 @@ describe('SessionPicker', () => {
await wait(50);
const output = lastFrame();
// Should only show non-empty sessions from main branch
// Should only show sessions from main branch (including 0-message sessions)
expect(output).toContain('Valid main');
expect(output).not.toContain('Empty main');
expect(output).toContain('Empty main');
expect(output).not.toContain('Valid feature');
});
});
@ -292,11 +309,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -332,11 +351,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { stdin, unmount } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -365,11 +386,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { stdin } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -390,11 +413,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { stdin } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -423,11 +448,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -445,18 +472,20 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Resume Session');
expect(output).toContain('to navigate');
expect(output).toContain('↑↓ to navigate');
expect(output).toContain('Esc to cancel');
});
@ -467,12 +496,14 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
currentBranch="main"
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
@ -492,11 +523,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -515,11 +548,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
@ -569,11 +604,13 @@ describe('SessionPicker', () => {
const onCancel = vi.fn();
const { unmount } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(200);

View file

@ -4,182 +4,51 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { render, Box, Text, useApp } from 'ink';
import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { useSessionPicker } from '../hooks/useStandaloneSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js';
import { t } from '../../i18n/index.js';
import { useState } from 'react';
import { render, Box, useApp } from 'ink';
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
// Exported for testing
export interface SessionPickerProps {
interface StandalonePickerScreenProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
}
// Prefix characters for standalone fullscreen picker
const STANDALONE_PREFIX_CHARS = {
selected: ' ',
scrollUp: '↑ ',
scrollDown: '↓ ',
normal: ' ',
};
// Exported for testing
export function SessionPicker({
function StandalonePickerScreen({
sessionService,
currentBranch,
onSelect,
onCancel,
}: SessionPickerProps): React.JSX.Element {
currentBranch,
}: StandalonePickerScreenProps): React.JSX.Element {
const { exit } = useApp();
const [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Update terminal size on resize
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Calculate visible items
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
const handleExit = () => {
setIsExiting(true);
exit();
};
const picker = useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection: true,
onExit: handleExit,
isActive: !isExiting,
});
// Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4;
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<Box
flexDirection="column"
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Main container with single border */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Resume Session')}
</Text>
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{picker.filterByBranch
? t('No sessions found for branch "{{branch}}"', {
branch: currentBranch ?? '',
})
: t('No sessions found')}
</Text>
</Box>
) : (
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<SessionListItemView
key={session.sessionId}
session={session}
isSelected={actualIndex === picker.selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={promptMaxWidth}
prefixChars={STANDALONE_PREFIX_CHARS}
boldSelectedPrefix={false}
/>
);
})
)}
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Footer with keyboard shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text
bold={picker.filterByBranch}
color={picker.filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{t(' to toggle branch') + ' · '}
</>
)}
{t('to navigate · Esc to cancel')}
</Text>
</Box>
</Box>
</Box>
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
/>
);
}
@ -205,8 +74,6 @@ export async function showResumeSessionPicker(
return undefined;
}
const currentBranch = getGitBranch(cwd);
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
@ -220,16 +87,18 @@ export async function showResumeSessionPicker(
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<SessionPicker
sessionService={sessionService}
currentBranch={currentBranch}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<StandalonePickerScreen
sessionService={sessionService}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
currentBranch={getGitBranch(cwd)}
/>
</KeypressProvider>,
{
exitOnCtrlC: false,
},