From f5c868702bf2b85b94d338dde8781d7bc34b0700 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Sat, 13 Dec 2025 09:20:58 +0100 Subject: [PATCH] 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`; +}