mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Refactor /resume command to use dialog instead of standalone Ink app
This commit is contained in:
@@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
|
|||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||||
|
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useVimMode } from './contexts/VimModeContext.js';
|
import { useVimMode } from './contexts/VimModeContext.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
@@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
|
|
||||||
// Additional hooks moved from App.tsx
|
// Additional hooks moved from App.tsx
|
||||||
const { stats: sessionStats } = useSessionStats();
|
const { stats: sessionStats, startNewSession } = useSessionStats();
|
||||||
const logger = useLogger(config.storage, sessionStats.sessionId);
|
const logger = useLogger(config.storage, sessionStats.sessionId);
|
||||||
const branchName = useGitBranchName(config.getTargetDir());
|
const branchName = useGitBranchName(config.getTargetDir());
|
||||||
|
|
||||||
@@ -435,6 +436,62 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||||
useModelCommand();
|
useModelCommand();
|
||||||
|
|
||||||
|
const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } =
|
||||||
|
useResumeCommand();
|
||||||
|
|
||||||
|
// Handle resume session selection
|
||||||
|
const handleResumeSessionSelect = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
SessionService,
|
||||||
|
buildApiHistoryFromConversation,
|
||||||
|
replayUiTelemetryFromConversation,
|
||||||
|
uiTelemetryService,
|
||||||
|
} = await import('@qwen-code/qwen-code-core');
|
||||||
|
const { buildResumedHistoryItems } = await import(
|
||||||
|
'./utils/resumeHistoryUtils.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
const cwd = config.getTargetDir();
|
||||||
|
const sessionService = new SessionService(cwd);
|
||||||
|
const sessionData = await sessionService.loadSession(sessionId);
|
||||||
|
|
||||||
|
if (!sessionData) {
|
||||||
|
closeResumeDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset and replay UI telemetry to restore metrics
|
||||||
|
uiTelemetryService.reset();
|
||||||
|
replayUiTelemetryFromConversation(sessionData.conversation);
|
||||||
|
|
||||||
|
// Build UI history items using existing utility
|
||||||
|
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
|
||||||
|
|
||||||
|
// Build API history for the LLM client
|
||||||
|
const clientHistory = buildApiHistoryFromConversation(
|
||||||
|
sessionData.conversation,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update client history
|
||||||
|
config.getGeminiClient()?.setHistory(clientHistory);
|
||||||
|
config.getGeminiClient()?.stripThoughtsFromHistory();
|
||||||
|
|
||||||
|
// Update session in config
|
||||||
|
config.startNewSession(sessionId);
|
||||||
|
startNewSession(sessionId);
|
||||||
|
|
||||||
|
// Clear and load history
|
||||||
|
historyManager.clearItems();
|
||||||
|
historyManager.loadHistory(uiHistoryItems);
|
||||||
|
|
||||||
|
closeResumeDialog();
|
||||||
|
},
|
||||||
|
[config, closeResumeDialog, historyManager, startNewSession],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showWorkspaceMigrationDialog,
|
showWorkspaceMigrationDialog,
|
||||||
workspaceExtensions,
|
workspaceExtensions,
|
||||||
@@ -488,6 +545,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
openAgentsManagerDialog,
|
openAgentsManagerDialog,
|
||||||
|
openResumeDialog,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
@@ -502,6 +560,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
openAgentsManagerDialog,
|
openAgentsManagerDialog,
|
||||||
|
openResumeDialog,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1222,6 +1281,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
|
isResumeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1312,6 +1372,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
|
isResumeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1421,6 +1482,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog,
|
closeSubagentCreateDialog,
|
||||||
closeAgentsManagerDialog,
|
closeAgentsManagerDialog,
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResumeSessionSelect,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
@@ -1453,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog,
|
closeSubagentCreateDialog,
|
||||||
closeAgentsManagerDialog,
|
closeAgentsManagerDialog,
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResumeSessionSelect,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,9 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
SlashCommand,
|
|
||||||
SlashCommandActionReturn,
|
|
||||||
CommandContext,
|
|
||||||
} from './types.js';
|
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import { showResumeSessionPicker } from '../components/ResumeSessionPicker.js';
|
|
||||||
import {
|
|
||||||
SessionService,
|
|
||||||
buildApiHistoryFromConversation,
|
|
||||||
replayUiTelemetryFromConversation,
|
|
||||||
uiTelemetryService,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
|
|
||||||
|
|
||||||
export const resumeCommand: SlashCommand = {
|
export const resumeCommand: SlashCommand = {
|
||||||
name: 'resume',
|
name: 'resume',
|
||||||
@@ -26,64 +14,8 @@ export const resumeCommand: SlashCommand = {
|
|||||||
get description() {
|
get description() {
|
||||||
return t('Resume a previous session');
|
return t('Resume a previous session');
|
||||||
},
|
},
|
||||||
action: async (
|
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||||
context: CommandContext,
|
type: 'dialog',
|
||||||
): Promise<void | SlashCommandActionReturn> => {
|
dialog: 'resume',
|
||||||
const { config } = context.services;
|
}),
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: 'Config not available',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the session picker
|
|
||||||
const cwd = config.getTargetDir();
|
|
||||||
const selectedSessionId = await showResumeSessionPicker(cwd);
|
|
||||||
|
|
||||||
if (!selectedSessionId) {
|
|
||||||
// User cancelled
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the session data
|
|
||||||
const sessionService = new SessionService(cwd);
|
|
||||||
const sessionData = await sessionService.loadSession(selectedSessionId);
|
|
||||||
|
|
||||||
if (!sessionData) {
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: `Could not load session: ${selectedSessionId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset and replay UI telemetry to restore metrics
|
|
||||||
uiTelemetryService.reset();
|
|
||||||
replayUiTelemetryFromConversation(sessionData.conversation);
|
|
||||||
|
|
||||||
// Build UI history items using existing utility
|
|
||||||
const uiHistoryWithIds = buildResumedHistoryItems(sessionData, config);
|
|
||||||
// Strip IDs for LoadHistoryActionReturn (IDs are re-assigned by loadHistory)
|
|
||||||
const uiHistory = uiHistoryWithIds.map(({ id: _id, ...rest }) => rest);
|
|
||||||
|
|
||||||
// Build API history for the LLM client
|
|
||||||
const clientHistory = buildApiHistoryFromConversation(
|
|
||||||
sessionData.conversation,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update session in config and context
|
|
||||||
config.startNewSession(selectedSessionId);
|
|
||||||
if (context.session.startNewSession) {
|
|
||||||
context.session.startNewSession(selectedSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'load_history',
|
|
||||||
history: uiHistory,
|
|
||||||
clientHistory,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export interface OpenDialogActionReturn {
|
|||||||
| 'subagent_create'
|
| 'subagent_create'
|
||||||
| 'subagent_list'
|
| 'subagent_list'
|
||||||
| 'permissions'
|
| 'permissions'
|
||||||
| 'approval-mode';
|
| 'approval-mode'
|
||||||
|
| 'resume';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
|||||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||||
|
import { ResumeSessionDialog } from './ResumeSessionDialog.js';
|
||||||
|
|
||||||
interface DialogManagerProps {
|
interface DialogManagerProps {
|
||||||
addItem: UseHistoryManagerReturn['addItem'];
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
@@ -290,5 +291,18 @@ export const DialogManager = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uiState.isResumeDialogOpen) {
|
||||||
|
return (
|
||||||
|
<ResumeSessionDialog
|
||||||
|
cwd={config.getTargetDir()}
|
||||||
|
onSelect={uiActions.handleResumeSessionSelect}
|
||||||
|
onCancel={uiActions.closeResumeDialog}
|
||||||
|
availableTerminalHeight={
|
||||||
|
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
346
packages/cli/src/ui/components/ResumeSessionDialog.tsx
Normal file
346
packages/cli/src/ui/components/ResumeSessionDialog.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Code
|
||||||
|
* 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 { theme } from '../semantic-colors.js';
|
||||||
|
import { formatRelativeTime } from '../utils/formatters.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export interface ResumeSessionDialogProps {
|
||||||
|
cwd: string;
|
||||||
|
onSelect: (sessionId: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
padding={1}
|
||||||
|
>
|
||||||
|
<Text color={theme.text.primary} bold>
|
||||||
|
Resume Session
|
||||||
|
</Text>
|
||||||
|
<Box paddingY={1}>
|
||||||
|
<Text color={theme.text.secondary}>Loading sessions...</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
padding={1}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color={theme.text.primary} bold>
|
||||||
|
Resume Session
|
||||||
|
</Text>
|
||||||
|
{filterByBranch && currentBranch && (
|
||||||
|
<Text color={theme.text.secondary}> (branch: {currentBranch})</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Session List */}
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
{filteredSessions.length === 0 ? (
|
||||||
|
<Box paddingY={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{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,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
borderStyle="single"
|
||||||
|
borderTop
|
||||||
|
borderBottom={false}
|
||||||
|
borderLeft={false}
|
||||||
|
borderRight={false}
|
||||||
|
paddingTop={1}
|
||||||
|
>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{currentBranch && (
|
||||||
|
<>
|
||||||
|
<Text color={theme.text.accent} bold>
|
||||||
|
B
|
||||||
|
</Text>
|
||||||
|
{' to toggle branch · '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{'↑↓ to navigate · Enter to select · Esc to cancel'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ export interface UIActions {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog: () => void;
|
closeSubagentCreateDialog: () => void;
|
||||||
closeAgentsManagerDialog: () => void;
|
closeAgentsManagerDialog: () => void;
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog: () => void;
|
||||||
|
closeResumeDialog: () => void;
|
||||||
|
handleResumeSessionSelect: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface UIState {
|
|||||||
isModelDialogOpen: boolean;
|
isModelDialogOpen: boolean;
|
||||||
isPermissionsDialogOpen: boolean;
|
isPermissionsDialogOpen: boolean;
|
||||||
isApprovalModeDialogOpen: boolean;
|
isApprovalModeDialogOpen: boolean;
|
||||||
|
isResumeDialogOpen: boolean;
|
||||||
slashCommands: readonly SlashCommand[];
|
slashCommands: readonly SlashCommand[];
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
commandContext: CommandContext;
|
commandContext: CommandContext;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface SlashCommandProcessorActions {
|
|||||||
openModelDialog: () => void;
|
openModelDialog: () => void;
|
||||||
openPermissionsDialog: () => void;
|
openPermissionsDialog: () => void;
|
||||||
openApprovalModeDialog: () => void;
|
openApprovalModeDialog: () => void;
|
||||||
|
openResumeDialog: () => void;
|
||||||
quit: (messages: HistoryItem[]) => void;
|
quit: (messages: HistoryItem[]) => void;
|
||||||
setDebugMessage: (message: string) => void;
|
setDebugMessage: (message: string) => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
@@ -417,6 +418,9 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'approval-mode':
|
case 'approval-mode':
|
||||||
actions.openApprovalModeDialog();
|
actions.openApprovalModeDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'resume':
|
||||||
|
actions.openResumeDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
case 'help':
|
case 'help':
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
25
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
25
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Code
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useResumeCommand() {
|
||||||
|
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const openResumeDialog = useCallback(() => {
|
||||||
|
setIsResumeDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeResumeDialog = useCallback(() => {
|
||||||
|
setIsResumeDialogOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isResumeDialogOpen,
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user