Refactor /resume command to use dialog instead of standalone Ink app

This commit is contained in:
Alexander Farber
2025-12-12 21:34:26 +01:00
parent 2de50ae436
commit 12877ac849
9 changed files with 471 additions and 75 deletions

View File

@@ -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,
], ],
); );

View File

@@ -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,
};
},
}; };

View File

@@ -124,7 +124,8 @@ export interface OpenDialogActionReturn {
| 'subagent_create' | 'subagent_create'
| 'subagent_list' | 'subagent_list'
| 'permissions' | 'permissions'
| 'approval-mode'; | 'approval-mode'
| 'resume';
} }
/** /**

View File

@@ -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;
}; };

View 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>
);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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: {

View 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,
};
}