/** * @license * Copyright 2025 Qwen Code * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import type { SessionListItem as SessionData, SessionService, } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useSessionPicker } from '../hooks/useSessionPicker.js'; import { formatRelativeTime } from '../utils/formatters.js'; import { formatMessageCount, truncateText, } from '../utils/sessionPickerUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { t } from '../../i18n/index.js'; export interface SessionPickerProps { sessionService: SessionService | null; onSelect: (sessionId: string) => void; onCancel: () => void; currentBranch?: string; /** * Scroll mode. When true, keep selection centered (fullscreen-style). * Defaults to true so dialog + standalone behave identically. */ centerSelection?: boolean; } const PREFIX_CHARS = { selected: '› ', scrollUp: '↑ ', scrollDown: '↓ ', normal: ' ', }; interface SessionListItemViewProps { session: SessionData; isSelected: boolean; isFirst: boolean; isLast: boolean; showScrollUp: boolean; showScrollDown: boolean; maxPromptWidth: number; prefixChars?: { selected: string; scrollUp: string; scrollDown: string; normal: string; }; boldSelectedPrefix?: boolean; } function SessionListItemView({ session, isSelected, isFirst, isLast, showScrollUp, showScrollDown, maxPromptWidth, prefixChars = PREFIX_CHARS, boldSelectedPrefix = true, }: SessionListItemViewProps): React.JSX.Element { const timeAgo = formatRelativeTime(session.mtime); const messageText = formatMessageCount(session.messageCount); const showUpIndicator = isFirst && showScrollUp; const showDownIndicator = isLast && showScrollDown; const prefix = isSelected ? prefixChars.selected : showUpIndicator ? prefixChars.scrollUp : showDownIndicator ? prefixChars.scrollDown : prefixChars.normal; const promptText = session.prompt || '(empty prompt)'; const truncatedPrompt = truncateText(promptText, maxPromptWidth); return ( {prefix} {truncatedPrompt} {timeAgo} · {messageText} {session.gitBranch && ` · ${session.gitBranch}`} ); } export function SessionPicker(props: SessionPickerProps) { const { sessionService, onSelect, onCancel, currentBranch, centerSelection = true, } = props; const { columns: width, rows: height } = useTerminalSize(); // Calculate box width (marginX={2}) const boxWidth = width - 4; // Calculate visible items (same heuristic as before) // Reserved space: header (1), footer (1), separators (2), borders (2) const reservedLines = 6; // Each item takes 2 lines (prompt + metadata) + 1 line margin between items const itemHeight = 3; const maxVisibleItems = Math.max( 1, Math.floor((height - reservedLines) / itemHeight), ); const picker = useSessionPicker({ sessionService, currentBranch, onSelect, onCancel, maxVisibleItems, centerSelection, isActive: true, }); return ( {/* Header row */} {t('Resume Session')} {picker.filterByBranch && currentBranch && ( {' '} {t('(branch: {{branch}})', { branch: currentBranch })} )} {/* Separator */} {'─'.repeat(boxWidth - 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(boxWidth - 2)} {/* Footer */} {currentBranch && ( B {t(' to toggle branch')} · )} {t('↑↓ to navigate · Esc to cancel')} ); }