Put shared code in new files

This commit is contained in:
Alexander Farber
2025-12-13 09:20:58 +01:00
parent 0d40cf2213
commit f5c868702b
5 changed files with 512 additions and 478 deletions

View File

@@ -4,18 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js'; import { useSessionPicker } from '../hooks/useSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js';
const PAGE_SIZE = 20;
export interface ResumeSessionDialogProps { export interface ResumeSessionDialogProps {
cwd: string; cwd: string;
@@ -24,186 +18,39 @@ export interface ResumeSessionDialogProps {
availableTerminalHeight?: number; 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({ export function ResumeSessionDialog({
cwd, cwd,
onSelect, onSelect,
onCancel, onCancel,
availableTerminalHeight, availableTerminalHeight,
}: ResumeSessionDialogProps): React.JSX.Element { }: 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<string | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [scrollOffset, setScrollOffset] = useState(0);
const sessionServiceRef = useRef<SessionService | null>(null); const sessionServiceRef = useRef<SessionService | null>(null);
const isLoadingMoreRef = useRef(false); 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 // Calculate visible items based on terminal height
const maxVisibleItems = availableTerminalHeight const maxVisibleItems = availableTerminalHeight
? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3))
: 5; : 5;
// Initialize session service and load sessions const picker = useSessionPicker({
useEffect(() => { sessionService: sessionServiceRef.current!,
const sessionService = new SessionService(cwd); currentBranch,
sessionServiceRef.current = sessionService; onSelect,
onCancel,
const branch = getGitBranch(cwd); maxVisibleItems,
setCurrentBranch(branch); centerSelection: false,
isActive: isReady,
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 if (!isReady || picker.isLoading) {
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 ( return (
<Box <Box
flexDirection="column" flexDirection="column"
@@ -233,87 +80,35 @@ export function ResumeSessionDialog({
<Text color={theme.text.primary} bold> <Text color={theme.text.primary} bold>
Resume Session Resume Session
</Text> </Text>
{filterByBranch && currentBranch && ( {picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}> (branch: {currentBranch})</Text> <Text color={theme.text.secondary}> (branch: {currentBranch})</Text>
)} )}
</Box> </Box>
{/* Session List */} {/* Session List */}
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
{filteredSessions.length === 0 ? ( {picker.filteredSessions.length === 0 ? (
<Box paddingY={1}> <Box paddingY={1}>
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{filterByBranch {picker.filterByBranch
? `No sessions found for branch "${currentBranch}"` ? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'} : 'No sessions found'}
</Text> </Text>
</Box> </Box>
) : ( ) : (
visibleSessions.map((session, visibleIndex) => { picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex; const actualIndex = picker.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 ( return (
<Box <SessionListItemView
key={session.sessionId} key={session.sessionId}
flexDirection="column" session={session}
marginBottom={isLast ? 0 : 1} isSelected={actualIndex === picker.selectedIndex}
> isFirst={visibleIndex === 0}
{/* First line: prefix + prompt text */} isLast={visibleIndex === picker.visibleSessions.length - 1}
<Box> showScrollUp={picker.showScrollUp}
<Text showScrollDown={picker.showScrollDown}
color={ maxPromptWidth={(process.stdout.columns || 80) - 10}
isSelected />
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected}
>
{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

@@ -4,18 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink'; import { render, Box, Text, useApp } from 'ink';
import { import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js'; import { useSessionPicker } from '../hooks/useSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js';
const PAGE_SIZE = 20;
// Exported for testing // Exported for testing
export interface SessionPickerProps { export interface SessionPickerProps {
@@ -25,14 +19,13 @@ export interface SessionPickerProps {
onCancel: () => void; onCancel: () => void;
} }
/** // Prefix characters for standalone fullscreen picker
* Truncates text to fit within a given width, adding ellipsis if needed. const STANDALONE_PREFIX_CHARS = {
*/ selected: ' ',
function truncateText(text: string, maxWidth: number): string { scrollUp: '↑ ',
if (text.length <= maxWidth) return text; scrollDown: '↓ ',
if (maxWidth <= 3) return text.slice(0, maxWidth); normal: ' ',
return text.slice(0, maxWidth - 3) + '...'; };
}
// Exported for testing // Exported for testing
export function SessionPicker({ export function SessionPicker({
@@ -42,18 +35,6 @@ export function SessionPicker({
onCancel, onCancel,
}: SessionPickerProps): React.JSX.Element { }: SessionPickerProps): React.JSX.Element {
const { exit } = useApp(); 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 [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({ const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80, 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 // Calculate visible items
// Reserved space: header (1), footer (1), separators (2), borders (2) // Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6; const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items // 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 itemHeight = 3;
const maxVisibleItems = Math.max( const maxVisibleItems = Math.max(
1, 1,
Math.floor((terminalSize.height - reservedLines) / itemHeight), Math.floor((terminalSize.height - reservedLines) / itemHeight),
); );
// Calculate scroll offset const handleExit = () => {
const scrollOffset = (() => { setIsExiting(true);
if (filteredSessions.length <= maxVisibleItems) return 0; exit();
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 visibleSessions = filteredSessions.slice( const picker = useSessionPicker({
scrollOffset, sessionService,
scrollOffset + maxVisibleItems, currentBranch,
); onSelect,
const showScrollUp = scrollOffset > 0; onCancel,
const showScrollDown = maxVisibleItems,
scrollOffset + maxVisibleItems < filteredSessions.length; centerSelection: true,
onExit: handleExit,
// Sentinel (invisible) sits after the last session item; consider it visible isActive: !isExiting,
// 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;
}
}); });
// 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) // Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4; const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4; // Account for " " prefix const promptMaxWidth = contentWidth - 4;
// Return empty while exiting to prevent visual glitches // Return empty while exiting to prevent visual glitches
if (isExiting) { if (isExiting) {
@@ -265,80 +122,30 @@ export function SessionPicker({
{/* Session list with auto-scrolling */} {/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden"> <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{filteredSessions.length === 0 ? ( {picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center"> <Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{filterByBranch {picker.filterByBranch
? `No sessions found for branch "${currentBranch}"` ? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'} : 'No sessions found'}
</Text> </Text>
</Box> </Box>
) : ( ) : (
visibleSessions.map((session, visibleIndex) => { picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex; const actualIndex = picker.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
? '↓ '
: ' ';
return ( return (
<Box <SessionListItemView
key={session.sessionId} key={session.sessionId}
flexDirection="column" session={session}
marginBottom={isLast ? 0 : 1} isSelected={actualIndex === picker.selectedIndex}
> isFirst={visibleIndex === 0}
{/* First line: prefix (selector or scroll indicator) + prompt text */} isLast={visibleIndex === picker.visibleSessions.length - 1}
<Box> showScrollUp={picker.showScrollUp}
<Text showScrollDown={picker.showScrollDown}
color={ maxPromptWidth={promptMaxWidth}
isSelected prefixChars={STANDALONE_PREFIX_CHARS}
? theme.text.accent boldSelectedPrefix={false}
: showUpIndicator || showDownIndicator />
? theme.text.secondary
: undefined
}
>
{prefix}
</Text>
<Text
bold={isSelected}
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{truncateText(
session.prompt || '(empty prompt)',
promptMaxWidth,
)}
</Text>
</Box>
{/* Second line: metadata (aligned with prompt text) */}
<Box>
<Text>{' '}</Text>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
); );
}) })
)} )}
@@ -357,8 +164,8 @@ export function SessionPicker({
{currentBranch && ( {currentBranch && (
<> <>
<Text <Text
bold={filterByBranch} bold={picker.filterByBranch}
color={filterByBranch ? theme.text.accent : undefined} color={picker.filterByBranch ? theme.text.accent : undefined}
> >
B B
</Text> </Text>

View File

@@ -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 (
<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 { 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<void>;
}
export function useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection = false,
onExit,
isActive = true,
}: UseSessionPickerOptions): UseSessionPickerResult {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<SessionState>({
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,
};
}

View File

@@ -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`;
}