From 94c3eecb99318ea52065e0af1d5e404915fdac2e Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:54:09 -0700 Subject: [PATCH] feat(sessions): add /resume slash command to open the session browser (#13621) --- .../src/services/BuiltinCommandLoader.test.ts | 1 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/test-utils/render.tsx | 4 ++ packages/cli/src/ui/AppContainer.tsx | 33 ++++++++++- packages/cli/src/ui/commands/resumeCommand.ts | 25 ++++++++ packages/cli/src/ui/commands/types.ts | 1 + .../cli/src/ui/components/DialogManager.tsx | 11 ++++ .../src/ui/components/InputPrompt.test.tsx | 6 ++ .../src/ui/components/SessionBrowser.test.tsx | 17 +++++- .../cli/src/ui/components/SessionBrowser.tsx | 59 +++++++++---------- .../cli/src/ui/contexts/UIActionsContext.tsx | 5 ++ .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 ++ .../cli/src/ui/hooks/useSessionBrowser.ts | 6 +- packages/core/src/core/geminiChat.ts | 2 +- 16 files changed, 142 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/ui/commands/resumeCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 09de4b4fa8..332a0377d0 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -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: {} })); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3646b2d036..d0bc8b4ff9 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 74335963b1..894fd06568 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -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(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2435976f9d..461d105b89 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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 => { + 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, diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts new file mode 100644 index 0000000000..6b89daf38a --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -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 => ({ + type: 'dialog', + dialog: 'sessionBrowser', + }), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 56be07acdc..828bbe6ff6 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -123,6 +123,7 @@ export interface OpenDialogActionReturn { | 'editor' | 'privacy' | 'settings' + | 'sessionBrowser' | 'model' | 'permissions'; } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f50a10a7da..2d04e533db 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -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 ( + + ); + } if (uiState.isPermissionsDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 33e76b0f69..8305f916f1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -86,6 +86,12 @@ const mockSlashCommands: SlashCommand[] = [ }, ], }, + { + name: 'resume', + description: 'Browse and resume sessions', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, ]; describe('InputPrompt', () => { diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx index 2a2f239d11..9d72542b1c 100644 --- a/packages/cli/src/ui/components/SessionBrowser.test.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -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( , @@ -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( , @@ -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( , @@ -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( , @@ -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( , diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index ff52945f7b..ece909bd07 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -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; /** 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, 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)); } } diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index d9de9994d5..b9e083db66 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -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; + handleDeleteSession: (session: SessionInfo) => Promise; setQueueErrorMessage: (message: string | null) => void; popAllMessages: (onPop: (messages: string | undefined) => void) => void; handleApiKeySubmit: (apiKey: string) => Promise; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 907a374cb1..34e6262f30 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -59,6 +59,7 @@ export interface UIState { debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; + isSessionBrowserOpen: boolean; isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; permissionsDialogProps: { targetDirectory?: string } | null; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 76539e5a2f..77be67c303 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -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, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 59a5e8dade..2b43e1bb7b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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' }; diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 2dec70901d..8d048b3efe 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -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--.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); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index c06285f4d4..3e48bcfcb9 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -469,7 +469,7 @@ export class GeminiChat { : undefined, }); - return this.processStreamResponse(model, streamResponse); + return this.processStreamResponse(effectiveModel, streamResponse); } /**