From 12877ac8497809ef9647c58c4be3ed5ff69e4fc7 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 21:34:26 +0100 Subject: [PATCH] Refactor /resume command to use dialog instead of standalone Ink app --- packages/cli/src/ui/AppContainer.tsx | 71 +++- packages/cli/src/ui/commands/resumeCommand.ts | 78 +--- packages/cli/src/ui/commands/types.ts | 3 +- .../cli/src/ui/components/DialogManager.tsx | 14 + .../src/ui/components/ResumeSessionDialog.tsx | 346 ++++++++++++++++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + packages/cli/src/ui/hooks/useResumeCommand.ts | 25 ++ 9 files changed, 471 insertions(+), 75 deletions(-) create mode 100644 packages/cli/src/ui/components/ResumeSessionDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useResumeCommand.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ff16c53d..2ee02db4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; +import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => { const { stdout } = useStdout(); // Additional hooks moved from App.tsx - const { stats: sessionStats } = useSessionStats(); + const { stats: sessionStats, startNewSession } = useSessionStats(); const logger = useLogger(config.storage, sessionStats.sessionId); const branchName = useGitBranchName(config.getTargetDir()); @@ -435,6 +436,62 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = 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 { showWorkspaceMigrationDialog, workspaceExtensions, @@ -488,6 +545,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, }), [ openAuthDialog, @@ -502,6 +560,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, ], ); @@ -1222,6 +1281,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1312,6 +1372,7 @@ export const AppContainer = (props: AppContainerProps) => { isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1421,6 +1482,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResumeSessionSelect, }), [ handleThemeSelect, @@ -1453,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResumeSessionSelect, ], ); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 96aac2c0..20592bf3 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -4,21 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - SlashCommand, - SlashCommandActionReturn, - CommandContext, -} from './types.js'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.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 = { name: 'resume', @@ -26,64 +14,8 @@ export const resumeCommand: SlashCommand = { get description() { return t('Resume a previous session'); }, - action: async ( - context: CommandContext, - ): Promise => { - 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, - }; - }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'resume', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f2ec2173..8bcc872f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -124,7 +124,8 @@ export interface OpenDialogActionReturn { | 'subagent_create' | 'subagent_list' | 'permissions' - | 'approval-mode'; + | 'approval-mode' + | 'resume'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d696c87a..c0907400 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { ResumeSessionDialog } from './ResumeSessionDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -290,5 +291,18 @@ export const DialogManager = ({ ); } + if (uiState.isResumeDialogOpen) { + return ( + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.tsx new file mode 100644 index 00000000..c83e80eb --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionDialog.tsx @@ -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(); + const [isLoading, setIsLoading] = useState(true); + const [scrollOffset, setScrollOffset] = useState(0); + + const sessionServiceRef = useRef(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 ( + + + Resume Session + + + Loading sessions... + + + ); + } + + return ( + + {/* Header */} + + + Resume Session + + {filterByBranch && currentBranch && ( + (branch: {currentBranch}) + )} + + + {/* Session List */} + + {filteredSessions.length === 0 ? ( + + + {filterByBranch + ? `No sessions found for branch "${currentBranch}"` + : 'No sessions found'} + + + ) : ( + 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 ( + + {/* First line: prefix + prompt text */} + + + {prefix} + + + {truncatedPrompt} + + + {/* Second line: metadata */} + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); + }) + )} + + + {/* Footer */} + + + {currentBranch && ( + <> + + B + + {' to toggle branch · '} + + )} + {'↑↓ to navigate · Enter to select · Esc to cancel'} + + + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4788f7fa..f8456430 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -64,6 +64,10 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Resume session dialog + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResumeSessionSelect: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 62e54204..d009d59e 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -60,6 +60,7 @@ export interface UIState { isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; + isResumeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6439c934..ff7b5909 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -66,6 +66,7 @@ interface SlashCommandProcessorActions { openModelDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; + openResumeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -417,6 +418,9 @@ export const useSlashCommandProcessor = ( case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; + case 'resume': + actions.openResumeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts new file mode 100644 index 00000000..a0f683bf --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -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, + }; +}