mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Put shared code in new files
This commit is contained in:
@@ -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>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
108
packages/cli/src/ui/components/SessionListItem.tsx
Normal file
108
packages/cli/src/ui/components/SessionListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
275
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
49
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal 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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user