mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
251 lines
6.9 KiB
TypeScript
251 lines
6.9 KiB
TypeScript
/**
|
||
* @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);
|
||
});
|
||
});
|
||
}
|