Files
qwen-code/packages/cli/src/ui/components/ResumeSessionPicker.tsx
2025-12-13 13:08:07 +01:00

251 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
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 { useSessionPicker } from '../hooks/useSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js';
// Exported for testing
export interface SessionPickerProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
}
// Prefix characters for standalone fullscreen picker
const STANDALONE_PREFIX_CHARS = {
selected: ' ',
scrollUp: '↑ ',
scrollDown: '↓ ',
normal: ' ',
};
// Exported for testing
export function SessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
}: SessionPickerProps): React.JSX.Element {
const { exit } = useApp();
const [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Update terminal size on resize
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// 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
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
const handleExit = () => {
setIsExiting(true);
exit();
};
const picker = useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection: true,
onExit: handleExit,
isActive: !isExiting,
});
// Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4;
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<Box
flexDirection="column"
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Main container with single border */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
Resume Session
</Text>
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{picker.filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<SessionListItemView
key={session.sessionId}
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}
/>
);
})
)}
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Footer with keyboard shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text
bold={picker.filterByBranch}
color={picker.filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{' to toggle branch · '}
</>
)}
{'↑↓ to navigate · Esc to cancel'}
</Text>
</Box>
</Box>
</Box>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
const currentBranch = getGitBranch(cwd);
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<SessionPicker
sessionService={sessionService}
currentBranch={currentBranch}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
/>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}