mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-26 22:11:57 -07:00
feat(sessions): add /resume slash command to open the session browser (#13621)
This commit is contained in:
@@ -75,6 +75,7 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
}));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
||||
vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} }));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
|
||||
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
|
||||
@@ -32,6 +32,7 @@ import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
@@ -82,6 +83,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(isDevelopment ? [profileCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
|
||||
@@ -148,6 +148,10 @@ const mockUIActions: UIActions = {
|
||||
closeSettingsDialog: vi.fn(),
|
||||
closeModelDialog: vi.fn(),
|
||||
openPermissionsDialog: vi.fn(),
|
||||
openSessionBrowser: vi.fn(),
|
||||
closeSessionBrowser: vi.fn(),
|
||||
handleResumeSession: vi.fn(),
|
||||
handleDeleteSession: vi.fn(),
|
||||
closePermissionsDialog: vi.fn(),
|
||||
setShellModeActive: vi.fn(),
|
||||
vimHandleInput: vi.fn(),
|
||||
|
||||
@@ -99,6 +99,7 @@ import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
|
||||
import type { SessionInfo } from '../utils/sessionUtils.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
@@ -108,9 +109,10 @@ import {
|
||||
useExtensionUpdates,
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import { requestConsentInteractive } from '../config/extensions/consent.js';
|
||||
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
|
||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
||||
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
@@ -436,7 +438,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
|
||||
useSessionResume({
|
||||
const { loadHistoryForResume } = useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
@@ -445,6 +447,20 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
});
|
||||
const {
|
||||
isSessionBrowserOpen,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession: handleDeleteSessionSync,
|
||||
} = useSessionBrowser(config, loadHistoryForResume);
|
||||
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
||||
const handleDeleteSession = useCallback(
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
handleDeleteSessionSync(session);
|
||||
},
|
||||
[handleDeleteSessionSync],
|
||||
);
|
||||
|
||||
// Create handleAuthSelect wrapper for backward compatibility
|
||||
const handleAuthSelect = useCallback(
|
||||
@@ -570,6 +586,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openEditorDialog,
|
||||
openPrivacyNotice: () => setShowPrivacyNotice(true),
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openPermissionsDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
@@ -590,6 +607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openThemeDialog,
|
||||
openEditorDialog,
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
setQuittingMessages,
|
||||
setDebugMessage,
|
||||
@@ -1330,6 +1348,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showPrivacyNotice ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
isAuthDialogOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput;
|
||||
|
||||
@@ -1402,6 +1421,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
permissionsDialogProps,
|
||||
@@ -1492,6 +1512,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
permissionsDialogProps,
|
||||
@@ -1601,6 +1622,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
@@ -1632,6 +1657,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
|
||||
25
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
25
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
OpenDialogActionReturn,
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
export const resumeCommand: SlashCommand = {
|
||||
name: 'resume',
|
||||
description: 'Browse and resume auto-saved conversations',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'sessionBrowser',
|
||||
}),
|
||||
};
|
||||
@@ -123,6 +123,7 @@ export interface OpenDialogActionReturn {
|
||||
| 'editor'
|
||||
| 'privacy'
|
||||
| 'settings'
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -210,6 +211,16 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isSessionBrowserOpen) {
|
||||
return (
|
||||
<SessionBrowser
|
||||
config={config}
|
||||
onResumeSession={uiActions.handleResumeSession}
|
||||
onDeleteSession={uiActions.handleDeleteSession}
|
||||
onExit={uiActions.closeSessionBrowser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
return (
|
||||
|
||||
@@ -86,6 +86,12 @@ const mockSlashCommands: SlashCommand[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Browse and resume sessions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
describe('InputPrompt', () => {
|
||||
|
||||
@@ -57,7 +57,10 @@ vi.mock('./SessionBrowser.js', async (importOriginal) => {
|
||||
moveSelection,
|
||||
cycleSortOrder,
|
||||
props.onResumeSession,
|
||||
props.onDeleteSession,
|
||||
props.onDeleteSession ??
|
||||
(async () => {
|
||||
// no-op delete handler for tests that don't care about deletion
|
||||
}),
|
||||
props.onExit,
|
||||
);
|
||||
|
||||
@@ -146,12 +149,14 @@ describe('SessionBrowser component', () => {
|
||||
it('shows empty state when no sessions exist', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<TestSessionBrowser
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onExit={onExit}
|
||||
testSessions={[]}
|
||||
/>,
|
||||
@@ -181,12 +186,14 @@ describe('SessionBrowser component', () => {
|
||||
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<TestSessionBrowser
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onExit={onExit}
|
||||
testSessions={[session1, session2]}
|
||||
/>,
|
||||
@@ -230,12 +237,14 @@ describe('SessionBrowser component', () => {
|
||||
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<TestSessionBrowser
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onExit={onExit}
|
||||
testSessions={[searchSession, otherSession]}
|
||||
/>,
|
||||
@@ -279,12 +288,14 @@ describe('SessionBrowser component', () => {
|
||||
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<TestSessionBrowser
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onExit={onExit}
|
||||
testSessions={[session1, session2]}
|
||||
/>,
|
||||
@@ -323,7 +334,7 @@ describe('SessionBrowser component', () => {
|
||||
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onExit = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -348,12 +359,14 @@ describe('SessionBrowser component', () => {
|
||||
it('shows an error state when loading sessions fails', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<TestSessionBrowser
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onExit={onExit}
|
||||
testError="storage failure"
|
||||
/>,
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface SessionBrowserProps {
|
||||
/** Callback when user selects a session to resume */
|
||||
onResumeSession: (session: SessionInfo) => void;
|
||||
/** Callback when user deletes a session */
|
||||
onDeleteSession?: (session: SessionInfo) => void;
|
||||
onDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
/** Callback when user exits the session browser */
|
||||
onExit: () => void;
|
||||
}
|
||||
@@ -463,9 +463,11 @@ const SessionItem = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
|
||||
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
|
||||
const availableMessageWidth = Math.max(
|
||||
20,
|
||||
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH,
|
||||
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
|
||||
);
|
||||
|
||||
const truncatedMessage =
|
||||
@@ -759,7 +761,7 @@ export const useSessionBrowserInput = (
|
||||
moveSelection: (delta: number) => void,
|
||||
cycleSortOrder: () => void,
|
||||
onResumeSession: (session: SessionInfo) => void,
|
||||
onDeleteSession: ((session: SessionInfo) => void) | undefined,
|
||||
onDeleteSession: (session: SessionInfo) => Promise<void>,
|
||||
onExit: () => void,
|
||||
) => {
|
||||
useKeypress(
|
||||
@@ -817,38 +819,35 @@ export const useSessionBrowserInput = (
|
||||
else if (key.sequence === 'x' || key.sequence === 'X') {
|
||||
const selectedSession =
|
||||
state.filteredAndSortedSessions[state.activeIndex];
|
||||
if (
|
||||
selectedSession &&
|
||||
!selectedSession.isCurrentSession &&
|
||||
onDeleteSession
|
||||
) {
|
||||
try {
|
||||
onDeleteSession(selectedSession);
|
||||
// Remove the session from the state
|
||||
state.setSessions(
|
||||
state.sessions.filter((s) => s.id !== selectedSession.id),
|
||||
);
|
||||
|
||||
// Adjust active index if needed
|
||||
if (
|
||||
state.activeIndex >=
|
||||
state.filteredAndSortedSessions.length - 1
|
||||
) {
|
||||
state.setActiveIndex(
|
||||
Math.max(0, state.filteredAndSortedSessions.length - 2),
|
||||
if (selectedSession && !selectedSession.isCurrentSession) {
|
||||
onDeleteSession(selectedSession)
|
||||
.then(() => {
|
||||
// Remove the session from the state
|
||||
state.setSessions(
|
||||
state.sessions.filter((s) => s.id !== selectedSession.id),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
state.setError(
|
||||
`Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust active index if needed
|
||||
if (
|
||||
state.activeIndex >=
|
||||
state.filteredAndSortedSessions.length - 1
|
||||
) {
|
||||
state.setActiveIndex(
|
||||
Math.max(0, state.filteredAndSortedSessions.length - 2),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
state.setError(
|
||||
`Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// less-like u/d controls.
|
||||
else if (key.sequence === 'd') {
|
||||
else if (key.sequence === 'u') {
|
||||
moveSelection(-Math.round(SESSIONS_PER_PAGE / 2));
|
||||
} else if (key.sequence === 'u') {
|
||||
} else if (key.sequence === 'd') {
|
||||
moveSelection(Math.round(SESSIONS_PER_PAGE / 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { type AuthType, type EditorType } from '@google/gemini-cli-core';
|
||||
import { type LoadableSettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
|
||||
export interface UIActions {
|
||||
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
|
||||
@@ -45,6 +46,10 @@ export interface UIActions {
|
||||
handleProQuotaChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
openSessionBrowser: () => void;
|
||||
closeSessionBrowser: () => void;
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
popAllMessages: (onPop: (messages: string | undefined) => void) => void;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface UIState {
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSessionBrowserOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
permissionsDialogProps: { targetDirectory?: string } | null;
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
openEditorDialog: vi.fn(),
|
||||
openPrivacyNotice: vi.fn(),
|
||||
openSettingsDialog: vi.fn(),
|
||||
openSessionBrowser: vi.fn(),
|
||||
openModelDialog: mockOpenModelDialog,
|
||||
openPermissionsDialog: vi.fn(),
|
||||
quit: mockSetQuittingMessages,
|
||||
|
||||
@@ -52,6 +52,7 @@ interface SlashCommandProcessorActions {
|
||||
openEditorDialog: () => void;
|
||||
openPrivacyNotice: () => void;
|
||||
openSettingsDialog: () => void;
|
||||
openSessionBrowser: () => void;
|
||||
openModelDialog: () => void;
|
||||
openPermissionsDialog: (props?: { targetDirectory?: string }) => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
@@ -410,6 +411,9 @@ export const useSlashCommandProcessor = (
|
||||
case 'privacy':
|
||||
actions.openPrivacyNotice();
|
||||
return { type: 'handled' };
|
||||
case 'sessionBrowser':
|
||||
actions.openSessionBrowser();
|
||||
return { type: 'handled' };
|
||||
case 'settings':
|
||||
actions.openSettingsDialog();
|
||||
return { type: 'handled' };
|
||||
|
||||
@@ -91,12 +91,16 @@ export const useSessionBrowser = (
|
||||
*/
|
||||
handleDeleteSession: useCallback(
|
||||
(session: SessionInfo) => {
|
||||
// Note: Chat sessions are stored on disk using a filename derived from
|
||||
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
||||
// The ChatRecordingService.deleteSession API expects this file basename
|
||||
// (without the ".json" extension), not the full session UUID.
|
||||
try {
|
||||
const chatRecordingService = config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
chatRecordingService.deleteSession(session.id);
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
|
||||
Reference in New Issue
Block a user