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
*/
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<string | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [scrollOffset, setScrollOffset] = useState(0);
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
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 (
<Box
flexDirection="column"
@@ -233,87 +80,35 @@ export function ResumeSessionDialog({
<Text color={theme.text.primary} bold>
Resume Session
</Text>
{filterByBranch && currentBranch && (
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}> (branch: {currentBranch})</Text>
)}
</Box>
{/* Session List */}
<Box flexDirection="column" paddingX={1}>
{filteredSessions.length === 0 ? (
{picker.filteredSessions.length === 0 ? (
<Box paddingY={1}>
<Text color={theme.text.secondary}>
{filterByBranch
{picker.filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
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 (
<Box
<SessionListItemView
key={session.sessionId}
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}
>
{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>
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}
/>
);
})
)}

View File

@@ -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 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')) {
const handleExit = () => {
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 */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{filteredSessions.length === 0 ? (
{picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{filterByBranch
{picker.filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
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 (
<Box
<SessionListItemView
key={session.sessionId}
flexDirection="column"
marginBottom={isLast ? 0 : 1}
>
{/* First line: prefix (selector or scroll indicator) + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: 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>
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 && (
<>
<Text
bold={filterByBranch}
color={filterByBranch ? theme.text.accent : undefined}
bold={picker.filterByBranch}
color={picker.filterByBranch ? theme.text.accent : undefined}
>
B
</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`;
}