Compare commits

...

4 Commits

Author SHA1 Message Date
Dmitry Lyalin 56d5925a34 Merge branch 'main' into gemini-cli-resume-minimal 2026-03-23 15:42:43 -04:00
Dmitry Lyalin b6fe265d0c Merge remote-tracking branch 'origin/main' into gemini-cli-resume-minimal
# Conflicts:
#	docs/cli/cli-reference.md
#	packages/cli/src/gemini.test.tsx
#	packages/cli/src/ui/AppContainer.tsx
#	packages/cli/src/ui/components/DialogManager.tsx
#	packages/cli/src/ui/components/SessionBrowser.tsx
#	packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
#	packages/cli/src/ui/components/SessionSummaryDisplay.tsx
#	packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
#	packages/cli/src/ui/hooks/useSessionBrowser.test.ts
#	packages/cli/src/ui/hooks/useSessionResume.test.ts
#	packages/cli/src/utils/sessionUtils.test.ts
#	packages/cli/src/utils/sessionUtils.ts
#	packages/core/src/services/chatRecordingService.ts
2026-03-22 18:35:55 -04:00
Dmitry Lyalin 81006a7f82 docs: document global cross-folder session resume 2026-03-22 17:56:59 -04:00
Dmitry Lyalin 6c119110e4 Add global cross-folder session resume 2026-03-22 15:44:26 -04:00
37 changed files with 1327 additions and 250 deletions
+16 -16
View File
@@ -5,19 +5,19 @@ and parameters.
## CLI commands
| Command | Description | Example |
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `gemini` | Start interactive REPL | `gemini` |
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
| `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` |
| `gemini -r "<session-id>" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` |
| `gemini update` | Update to latest version | `gemini update` |
| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) |
| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) |
| Command | Description | Example |
| ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- |
| `gemini` | Start interactive REPL | `gemini` |
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
| `gemini -r "latest"` | Continue the most recent session across all folders | `gemini -r "latest"` |
| `gemini -r "latest" "query"` | Continue a previous session with a new prompt | `gemini -r "latest" "Check for type errors"` |
| `gemini -r "<session-id>" "query"` | Resume a specific session by UUID from any folder | `gemini -r "a1b2c3d4-e5f6-7890-abcd-ef1234567890" "Finish this PR"` |
| `gemini update` | Update to latest version | `gemini update` |
| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) |
| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) |
### Positional arguments
@@ -60,9 +60,9 @@ These commands are available within the interactive REPL.
| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) |
| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) |
| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit |
| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) |
| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit |
| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) |
| `--resume` | `-r` | string | - | Resume a previous session globally. Use `"latest"`, an index number from `--list-sessions`, or a full UUID (for example `--resume 5` or `--resume <uuid>`) |
| `--list-sessions` | - | boolean | - | List available sessions across all folders and exit |
| `--delete-session` | - | string | - | Delete a session globally by index number or UUID (use `--list-sessions` to see available sessions) |
| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) |
| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility |
| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` |
+28 -17
View File
@@ -15,11 +15,12 @@ session.
- All tool executions (inputs and outputs).
- Token usage statistics (input, output, cached, etc.).
- Assistant thoughts and reasoning summaries (when available).
- **Location:** Sessions are stored in `~/.gemini/tmp/<project_hash>/chats/`,
where `<project_hash>` is a unique identifier based on your project's root
- **Location:** Sessions are stored under `~/.gemini/tmp/<project_hash>/chats/`,
where `<project_hash>` is a unique identifier based on a project's root
directory.
- **Scope:** Sessions are project-specific. Switching directories to a different
project switches to that project's session history.
- **Scope:** Session storage remains organized per project, but session
discovery is global. You can list, resume, and delete auto-saved sessions from
any folder by index or UUID.
## Resuming sessions
@@ -38,20 +39,28 @@ sessions.
gemini --resume
```
This immediately loads the most recent session.
This immediately loads the most recent session across all folders.
- **Resume by index:** List available sessions first (see
[Listing sessions](#listing-sessions)), then use the index number:
[Listing sessions](#listing-sessions)), then use the index number from the
global session list:
```bash
gemini --resume 1
```
- **Resume by ID:** You can also provide the full session UUID:
- **Resume by ID:** You can also provide the full session UUID from any folder:
```bash
gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
If the current folder is different from the session's original folder:
- **Interactive mode:** Gemini CLI shows the original folder, your current
folder, and asks whether you want to resume the session in the current folder.
- **Non-interactive mode:** Gemini CLI exits with guidance telling you to `cd`
to the original folder and rerun the resume command there.
### From the interactive interface
While the CLI is running, use the `/resume` slash command to open the **Session
@@ -73,12 +82,13 @@ Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu.
The Session Browser provides an interactive interface where you can perform the
following actions:
- **Browse:** Scroll through a list of your past sessions.
- **Browse:** Scroll through a list of your past sessions across all folders.
- **Preview:** See details like the session date, message count, and the first
user prompt.
user prompt, plus the original folder path.
- **Search:** Press `/` to enter search mode, then type to filter sessions by ID
or content.
- **Select:** Press **Enter** to resume the selected session.
- **Select:** Press **Enter** to resume the selected session. If the session was
created in a different folder, Gemini CLI confirms before continuing there.
- **Esc:** Press **Esc** to exit the Session Browser.
### Manual chat checkpoints
@@ -109,8 +119,8 @@ space.
### Listing sessions
To see a list of all available sessions for the current project from the command
line, use the `--list-sessions` flag:
To see a list of all available sessions from the command line, use the
`--list-sessions` flag:
```bash
gemini --list-sessions
@@ -119,11 +129,11 @@ gemini --list-sessions
Output example:
```text
Available sessions for this project (3):
Available sessions (3):
1. Fix bug in auth (2 days ago) [a1b2c3d4]
2. Refactor database schema (5 hours ago) [e5f67890]
3. Update documentation (Just now) [abcd1234]
1. Fix bug in auth (2 days ago) [a1b2c3d4-e5f6-7890-abcd-ef1234567890] @ /Users/alex/project-a
2. Refactor database schema (5 hours ago) [e5f67890-1234-5678-9abc-def012345678] @ /Users/alex/project-b
3. Update documentation (Just now) [abcd1234-5678-90ef-abcd-1234567890ef] @ /Users/alex/project-c
```
### Deleting sessions
@@ -131,7 +141,8 @@ Available sessions for this project (3):
You can remove old or unwanted sessions to free up space or declutter your
history.
**From the command line:** Use the `--delete-session` flag with an index or ID:
**From the command line:** Use the `--delete-session` flag with an index or UUID
from the global list:
```bash
gemini --delete-session 2
+16 -1
View File
@@ -26,6 +26,19 @@ gemini -r
This restores your chat history and memory, so you can say "Continue with the
next step" immediately.
### Scenario: Resume a session from another folder
If you know the session UUID, you can resume it from any folder:
```bash
gemini -r a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
If that session was created in a different folder, Gemini CLI shows the original
folder and asks whether you want to continue in the current one. If you say no,
the CLI exits so you can `cd` to the original folder and rerun the command
there.
### Scenario: Browse past sessions
If you want to find a specific conversation from yesterday, use the interactive
@@ -33,11 +46,13 @@ browser.
**Command:** `/resume`
This opens a searchable list of all your past sessions. You'll see:
This opens a searchable list of all your past sessions across all folders.
You'll see:
- A timestamp (e.g., "2 hours ago").
- The first user message (helping you identify the topic).
- The number of turns in the conversation.
- The original folder for each session.
Select a session and press **Enter** to load it.
+5 -1
View File
@@ -362,8 +362,12 @@ Slash commands provide meta-level control over the CLI itself.
- **Search:** Use `/` to search through conversation content across all
sessions
- **Session Browser:** Interactive interface showing all saved sessions with
timestamps, message counts, and first user message for context
timestamps, message counts, the first user message for context, and the
original folder path
- **Sorting:** Sort sessions by date or message count
- **Cross-folder behavior:** `/resume` shows sessions across all folders. If you
choose a session that was created in a different folder, Gemini CLI shows the
original folder and asks whether you want to continue in the current folder.
- **Note:** All conversations are automatically saved as you chat - no manual
saving required. See [Session Management](../cli/session-management.md) for
complete details.
+11 -4
View File
@@ -2176,18 +2176,25 @@ for that specific session.
- Lists all available extensions and exits.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
provide a session index number from `--list-sessions`, or provide a full
session UUID.
- If no session_id is provided, defaults to "latest".
- Session discovery is global, so you can resume a session from any folder.
- If the current folder does not match the session's original folder,
interactive mode asks for confirmation before resuming there. In
non-interactive mode, the CLI exits with guidance to rerun from the original
folder instead.
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- List all available chat sessions across all folders and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
message, along with each session's original folder path when available.
- Example: `gemini --list-sessions`
- **`--delete-session <identifier>`**:
- Delete a specific chat session by its index number or full session UUID.
- Delete a specific chat session globally by its index number or full session
UUID.
- Use `--list-sessions` first to see available sessions, their indices, and
UUIDs.
- Example: `gemini --delete-session 3` or
+2 -3
View File
@@ -305,13 +305,12 @@ export async function parseArguments(
})
.option('list-sessions', {
type: 'boolean',
description:
'List available sessions for the current project and exit.',
description: 'List available sessions across all projects and exit.',
})
.option('delete-session', {
type: 'string',
description:
'Delete a session by index number (use --list-sessions to see available sessions).',
'Delete a session by index number or UUID (use --list-sessions to see available sessions).',
})
.option('include-directories', {
type: 'array',
+96 -25
View File
@@ -81,6 +81,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
createCache:
actual.createCache ??
((<K, V>() => {
const cache = new Map<K, V>();
return {
clear: () => cache.clear(),
getOrCreate: (key: K, factory: () => V) => {
if (!cache.has(key)) {
cache.set(key, factory());
}
return cache.get(key)!;
},
};
}) as typeof actual.createCache),
recordSlowRender: vi.fn(),
logUserPrompt: vi.fn(),
writeToStdout: vi.fn((...args) =>
@@ -724,8 +738,8 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should handle session selector error', async () => {
const { SessionSelector } = await import('./utils/sessionUtils.js');
vi.mocked(SessionSelector).mockImplementation(
const sessionUtils = await import('./utils/sessionUtils.js');
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
() =>
({
resolveSession: vi
@@ -781,16 +795,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should start normally with a warning when no sessions found for resume', async () => {
const { SessionSelector, SessionError } = await import(
'./utils/sessionUtils.js'
);
vi.mocked(SessionSelector).mockImplementation(
const sessionUtils = await import('./utils/sessionUtils.js');
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(SessionError.noSessionsFound()),
}) as unknown as InstanceType<typeof SessionSelector>,
.mockRejectedValue(sessionUtils.SessionError.noSessionsFound()),
}) as unknown as InstanceType<typeof sessionUtils.SessionSelector>,
);
const processExitSpy = vi
@@ -1044,6 +1056,16 @@ describe('gemini.tsx main function exit codes', () => {
});
it('should exit with 42 for session resume failure', async () => {
const sessionUtils = await import('./utils/sessionUtils.js');
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
}) as unknown as InstanceType<typeof sessionUtils.SessionSelector>,
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
isInteractive: () => false,
@@ -1063,29 +1085,78 @@ describe('gemini.tsx main function exit codes', () => {
resume: 'invalid-session',
} as unknown as CliArgs);
vi.mock('./utils/sessionUtils.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('./utils/sessionUtils.js')>();
return {
...original,
SessionSelector: vi.fn().mockImplementation(() => ({
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
})),
};
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
process.env['GEMINI_API_KEY'] = 'test-key';
try {
await main();
expect.fail('Should have thrown MockProcessExitError');
} catch (e) {
expect(e).toBeInstanceOf(MockProcessExitError);
expect((e as MockProcessExitError).code).toBe(42);
} finally {
delete process.env['GEMINI_API_KEY'];
}
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'error',
expect.stringContaining('Error resuming session: Session not found'),
);
expect(processExitSpy).toHaveBeenCalledWith(42);
});
it('should exit with 42 when resuming from a different folder in non-interactive mode', async () => {
const sessionUtils = await import('./utils/sessionUtils.js');
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
() =>
({
resolveSession: vi.fn().mockResolvedValue({
sessionPath: '/tmp/original-project/chats/session.json',
sessionData: {
sessionId: 'cross-project-session',
projectHash: 'test-project-hash',
startTime: '2025-01-01T00:00:00.000Z',
lastUpdated: '2025-01-01T01:00:00.000Z',
messages: [],
},
displayInfo: 'Cross-project session',
originProjectPath: '/tmp/original-project',
projectSlug: 'original-project',
isOriginProjectMismatch: true,
}),
}) as unknown as InstanceType<typeof sessionUtils.SessionSelector>,
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
isInteractive: () => false,
getQuestion: () => 'test',
getSandbox: () => undefined,
}),
);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({
resume: 'cross-project-session',
} as unknown as CliArgs);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
process.env['GEMINI_API_KEY'] = 'test-key';
try {
await main();
} finally {
delete process.env['GEMINI_API_KEY'];
}
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'error',
expect.stringContaining('Cannot resume session cross-project-session'),
);
expect(processExitSpy).toHaveBeenCalledWith(42);
});
it('should exit with 42 for no input provided', async () => {
+14
View File
@@ -32,6 +32,8 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
escapeShellArg,
getShellConfiguration,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -582,9 +584,21 @@ export async function main() {
const sessionSelector = new SessionSelector(config);
try {
const result = await sessionSelector.resolveSession(argv.resume);
if (!config.isInteractive() && result.isOriginProjectMismatch) {
const originalFolder = result.originProjectPath ?? 'unknown';
const { shell } = getShellConfiguration();
const rerunCommand = `cd ${escapeShellArg(originalFolder, shell)}\n gemini --resume ${escapeShellArg(result.sessionData.sessionId, shell)}`;
coreEvents.emitFeedback(
'error',
`Cannot resume session ${result.sessionData.sessionId} from "${process.cwd()}" in non-interactive mode.\n Original folder: ${originalFolder}\n Rerun from the original folder:\n ${rerunCommand}`,
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_INPUT_ERROR);
}
resumedSessionData = {
conversation: result.sessionData,
filePath: result.sessionPath,
originProjectPath: result.originProjectPath,
};
// Use the existing session ID to continue recording to the same session
config.setSessionId(resumedSessionData.conversation.sessionId);
+14
View File
@@ -99,6 +99,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
createCache:
original.createCache ??
((<K, V>() => {
const cache = new Map<K, V>();
return {
clear: () => cache.clear(),
getOrCreate: (key: K, factory: () => V) => {
if (!cache.has(key)) {
cache.set(key, factory());
}
return cache.get(key)!;
},
};
}) as typeof original.createCache),
ShellExecutionService: MockService,
};
});
@@ -117,6 +117,11 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getIdeMode: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true),
getPendingIncludeDirectories: vi.fn().mockReturnValue([]),
clearPendingIncludeDirectories: vi.fn(),
getWorkspaceContext: vi.fn().mockReturnValue({
addDirectories: vi.fn(),
}),
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
getUserCaching: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
+14
View File
@@ -65,6 +65,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
createCache:
actual.createCache ??
((<K, V>() => {
const cache = new Map<K, V>();
return {
clear: () => cache.clear(),
getOrCreate: (key: K, factory: () => V) => {
if (!cache.has(key)) {
cache.set(key, factory());
}
return cache.get(key)!;
},
};
}) as typeof actual.createCache),
coreEvents: mockCoreEvents,
IdeClient: mockIdeClient,
writeToStdout: vi.fn((...args) =>
+148 -36
View File
@@ -36,6 +36,7 @@ import {
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
type ResumeContextSwitchConfirmationRequest,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
@@ -77,6 +78,8 @@ import {
SessionStartSource,
SessionEndReason,
generateSummary,
escapeShellArg,
getShellConfiguration,
type ConsentRequestPayload,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
@@ -251,6 +254,10 @@ export const AppContainer = (props: AppContainerProps) => {
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
null,
);
const [
resumeContextSwitchConfirmationRequest,
setResumeContextSwitchConfirmationRequest,
] = useState<ResumeContextSwitchConfirmationRequest | null>(null);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const toggleBackgroundShellRef = useRef<() => void>(() => {});
@@ -273,6 +280,7 @@ export const AppContainer = (props: AppContainerProps) => {
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
const canResumeOnStartupRef = useRef(true);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
@@ -725,6 +733,46 @@ export const AppContainer = (props: AppContainerProps) => {
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
const confirmResumeContextSwitch = useCallback(
async ({
source,
sessionId,
currentProjectPath,
originProjectPath,
}: {
source: 'startup' | 'browser';
sessionId: string;
currentProjectPath: string;
originProjectPath: string;
}) =>
new Promise<boolean>((resolve) => {
const { shell } = getShellConfiguration();
const rerunCommand = `cd ${escapeShellArg(originProjectPath, shell)}\ngemini --resume ${escapeShellArg(sessionId, shell)}`;
const prompt =
source === 'startup'
? `Session ${sessionId} was created in:\n\n${originProjectPath}\n\nYou are currently in:\n\n${currentProjectPath}\n\nDo you want to resume it here instead? Choose "No" to exit and rerun from the original folder:\n\n${rerunCommand}`
: `Session ${sessionId} was created in:\n\n${originProjectPath}\n\nYou are currently in:\n\n${currentProjectPath}\n\nDo you want to resume it here instead?`;
setResumeContextSwitchConfirmationRequest({
prompt,
onConfirm: () => {
setResumeContextSwitchConfirmationRequest(null);
resolve(true);
},
onDecline: () => {
if (source !== 'startup') {
setResumeContextSwitchConfirmationRequest(null);
}
resolve(false);
},
exitOnDecline: source === 'startup',
declineExitMessage:
'Session resume was canceled. Exiting so you can switch to the original folder and rerun the resume command.',
exitCode: 0,
});
}),
[],
);
const { loadHistoryForResume, isResuming } = useSessionResume({
config,
@@ -734,6 +782,8 @@ export const AppContainer = (props: AppContainerProps) => {
setQuittingMessages,
resumedSessionData,
isAuthenticating,
canResumeOnStartup: () => canResumeOnStartupRef.current,
confirmResumeContextSwitch,
});
const {
isSessionBrowserOpen,
@@ -1456,31 +1506,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
const geminiClient = config.getGeminiClient();
useEffect(() => {
if (
initialPrompt &&
isConfigInitialized &&
!initialPromptSubmitted.current &&
!isAuthenticating &&
!isAuthDialogOpen &&
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!showPrivacyNotice &&
geminiClient?.isInitialized?.()
) {
void handleFinalSubmit(initialPrompt);
initialPromptSubmitted.current = true;
if (activePtyId) {
try {
ShellExecutionService.resizePty(
activePtyId,
Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
Math.max(
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
1,
),
);
} catch (e) {
// This can happen in a race condition where the pty exits
// right before we try to resize it.
if (
!(
e instanceof Error &&
e.message.includes('Cannot resize a pty that has already exited')
)
) {
throw e;
}
}
}
}, [
initialPrompt,
isConfigInitialized,
handleFinalSubmit,
isAuthenticating,
isAuthDialogOpen,
isThemeDialogOpen,
isEditorDialogOpen,
showPrivacyNotice,
geminiClient,
]);
}, [terminalWidth, availableTerminalHeight, activePtyId]);
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
const [currentIDE, setCurrentIDE] = useState<IdeInfo | null>(null);
@@ -1991,10 +2040,74 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, [historyManager]);
const nightly = props.version.includes('nightly');
const hasPendingIncludeDirectories =
config.getPendingIncludeDirectories().length > 0;
const hasStartupBlockingDialog =
adminSettingsChanged ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
isPolicyUpdateDialogOpen ||
isFolderTrustDialogOpen ||
!!resumeContextSwitchConfirmationRequest ||
isAuthenticating ||
authState === AuthState.AwaitingApiKeyInput ||
isAuthDialogOpen ||
showPrivacyNotice;
const visibleShouldShowIdePrompt =
!initialPrompt &&
!isResuming &&
!hasStartupBlockingDialog &&
shouldShowIdePrompt;
const visibleNewAgents =
!initialPrompt && !isResuming && !hasStartupBlockingDialog
? newAgents
: null;
canResumeOnStartupRef.current = !hasStartupBlockingDialog;
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
!!loopDetectionConfirmationRequest;
const hasBlockingUiForInitialPrompt =
hasStartupBlockingDialog ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
!!permissionConfirmationRequest ||
!!customDialog ||
hasConfirmUpdateExtensionRequests ||
hasLoopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
isEditorDialogOpen ||
isSessionBrowserOpen ||
hasPendingIncludeDirectories;
useEffect(() => {
if (
initialPrompt &&
isConfigInitialized &&
!initialPromptSubmitted.current &&
!hasBlockingUiForInitialPrompt &&
geminiClient?.isInitialized?.()
) {
void handleFinalSubmit(initialPrompt);
initialPromptSubmitted.current = true;
}
}, [
initialPrompt,
isConfigInitialized,
handleFinalSubmit,
hasBlockingUiForInitialPrompt,
geminiClient,
]);
const dialogsVisible =
shouldShowIdePrompt ||
shouldShowIdePrompt ||
visibleShouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
adminSettingsChanged ||
@@ -2002,6 +2115,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!authConsentRequest ||
!!permissionConfirmationRequest ||
!!customDialog ||
!!resumeContextSwitchConfirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
@@ -2020,7 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!emptyWalletRequest ||
isSessionBrowserOpen ||
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
!!visibleNewAgents;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -2032,15 +2146,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
!!loopDetectionConfirmationRequest;
const hasPendingActionRequired =
hasPendingToolConfirmation ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
!!resumeContextSwitchConfirmationRequest ||
hasConfirmUpdateExtensionRequests ||
hasLoopDetectionConfirmationRequest ||
!!proQuotaRequest ||
@@ -2215,11 +2325,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
shouldShowIdePrompt: visibleShouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
folderDiscoveryResults,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
resumeContextSwitchConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2288,7 +2399,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
newAgents: visibleNewAgents,
showIsExpandableHint,
hintMode:
config.isModelSteeringEnabled() &&
@@ -2342,11 +2453,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
visibleShouldShowIdePrompt,
isFolderTrustDialogOpen,
folderDiscoveryResults,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
resumeContextSwitchConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2415,7 +2527,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
activeBackgroundShellPid,
backgroundShells,
adminSettingsChanged,
newAgents,
visibleNewAgents,
showIsExpandableHint,
],
);
@@ -25,6 +25,13 @@ describe('quitCommand', () => {
it('returns a QuitActionReturn object with the correct messages', () => {
const mockContext = createMockCommandContext({
services: {
agentContext: {
config: {
getSessionId: () => 'test-session-id',
},
},
},
session: {
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
@@ -47,6 +54,7 @@ describe('quitCommand', () => {
{
type: 'quit',
duration: '1h 0m 0s',
sessionId: 'test-session-id',
id: expect.any(Number),
},
],
@@ -29,6 +29,7 @@ export const quitCommand: SlashCommand = {
{
type: 'quit',
duration: formatDuration(wallDuration),
sessionId: context.services.agentContext?.config.getSessionId(),
id: now,
},
],
@@ -25,6 +25,9 @@ vi.mock('./FolderTrustDialog.js', () => ({
vi.mock('./ConsentPrompt.js', () => ({
ConsentPrompt: () => <Text>ConsentPrompt</Text>,
}));
vi.mock('./ResumeContextSwitchDialog.js', () => ({
ResumeContextSwitchDialog: () => <Text>ResumeContextSwitchDialog</Text>,
}));
vi.mock('./ThemeDialog.js', () => ({
ThemeDialog: () => <Text>ThemeDialog</Text>,
}));
@@ -58,6 +61,9 @@ vi.mock('./ModelDialog.js', () => ({
vi.mock('./IdeTrustChangeDialog.js', () => ({
IdeTrustChangeDialog: () => <Text>IdeTrustChangeDialog</Text>,
}));
vi.mock('./NewAgentsNotification.js', () => ({
NewAgentsNotification: () => <Text>NewAgentsNotification</Text>,
}));
vi.mock('./AgentConfigDialog.js', () => ({
AgentConfigDialog: () => <Text>AgentConfigDialog</Text>,
}));
@@ -88,6 +94,7 @@ describe('DialogManager', () => {
loopDetectionConfirmationRequest: null,
confirmationRequest: null,
consentRequest: null,
resumeContextSwitchConfirmationRequest: null,
isThemeDialogOpen: false,
isSettingsDialogOpen: false,
isModelDialogOpen: false,
@@ -98,6 +105,8 @@ describe('DialogManager', () => {
showPrivacyNotice: false,
isPermissionsDialogOpen: false,
isAgentConfigDialogOpen: false,
newAgents: null,
customDialog: null,
selectedAgentName: undefined,
selectedAgentDisplayName: undefined,
selectedAgentDefinition: undefined,
@@ -165,6 +174,16 @@ describe('DialogManager', () => {
},
'ConsentPrompt',
],
[
{
resumeContextSwitchConfirmationRequest: {
prompt: 'resume here?',
onConfirm: vi.fn(),
onDecline: vi.fn(),
},
},
'ResumeContextSwitchDialog',
],
[{ isThemeDialogOpen: true }, 'ThemeDialog'],
[{ isSettingsDialogOpen: true }, 'SettingsDialog'],
[{ isModelDialogOpen: true }, 'ModelDialog'],
@@ -209,4 +228,46 @@ describe('DialogManager', () => {
unmount();
},
);
it('prioritizes folder trust ahead of resume context confirmation', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{
uiState: {
...baseUiState,
isFolderTrustDialogOpen: true,
resumeContextSwitchConfirmationRequest: {
prompt: 'resume here?',
onConfirm: vi.fn(),
onDecline: vi.fn(),
},
} as Partial<UIState> as UIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('FolderTrustDialog');
expect(lastFrame()).not.toContain('ResumeContextSwitchDialog');
unmount();
});
it('renders custom dialogs only after higher-priority startup dialogs', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{
uiState: {
...baseUiState,
resumeContextSwitchConfirmationRequest: {
prompt: 'resume here?',
onConfirm: vi.fn(),
onDecline: vi.fn(),
},
customDialog: <Text>CustomDialog</Text>,
} as Partial<UIState> as UIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('ResumeContextSwitchDialog');
expect(lastFrame()).not.toContain('CustomDialog');
unmount();
});
});
@@ -37,6 +37,7 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
import { ResumeContextSwitchDialog } from './ResumeContextSwitchDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -66,14 +67,6 @@ export const DialogManager = ({
if (uiState.showIdeRestartPrompt) {
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
}
if (uiState.newAgents) {
return (
<NewAgentsNotification
agents={uiState.newAgents}
onSelect={uiActions.handleNewAgentsSelect}
/>
);
}
if (uiState.quota.proQuotaRequest) {
return (
<ProQuotaDialog
@@ -126,11 +119,12 @@ export const DialogManager = ({
/>
);
}
if (uiState.shouldShowIdePrompt) {
if (uiState.isPolicyUpdateDialogOpen) {
return (
<IdeIntegrationNudge
ide={uiState.currentIDE!}
onComplete={uiActions.handleIdePromptComplete}
<PolicyUpdateDialog
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
@@ -143,12 +137,44 @@ export const DialogManager = ({
/>
);
}
if (uiState.isPolicyUpdateDialogOpen) {
if (uiState.resumeContextSwitchConfirmationRequest) {
return (
<PolicyUpdateDialog
<ResumeContextSwitchDialog
prompt={uiState.resumeContextSwitchConfirmationRequest.prompt}
onConfirm={uiState.resumeContextSwitchConfirmationRequest.onConfirm}
onDecline={uiState.resumeContextSwitchConfirmationRequest.onDecline}
exitOnDecline={
uiState.resumeContextSwitchConfirmationRequest.exitOnDecline
}
declineExitMessage={
uiState.resumeContextSwitchConfirmationRequest.declineExitMessage
}
exitCode={uiState.resumeContextSwitchConfirmationRequest.exitCode}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.showPrivacyNotice) {
return (
<PrivacyNotice
onExit={() => uiActions.exitPrivacyNotice()}
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
ide={uiState.currentIDE!}
onComplete={uiActions.handleIdePromptComplete}
/>
);
}
if (uiState.newAgents) {
return (
<NewAgentsNotification
agents={uiState.newAgents}
onSelect={uiActions.handleNewAgentsSelect}
/>
);
}
@@ -334,14 +360,6 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.showPrivacyNotice) {
return (
<PrivacyNotice
onExit={() => uiActions.exitPrivacyNotice()}
config={config}
/>
);
}
if (uiState.isSessionBrowserOpen) {
return (
<SessionBrowser
@@ -362,6 +380,9 @@ export const DialogManager = ({
/>
);
}
if (uiState.customDialog) {
return uiState.customDialog;
}
return null;
};
@@ -189,7 +189,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<ModelMessage model={itemForDisplay.model} />
)}
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
<SessionSummaryDisplay
duration={itemForDisplay.duration}
sessionId={itemForDisplay.sessionId}
/>
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { Text } from 'ink';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ResumeContextSwitchDialog } from './ResumeContextSwitchDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(() => null),
}));
vi.mock('../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: vi.fn(() => null),
}));
const mockedExit = vi.hoisted(() => vi.fn());
vi.mock('node:process', async () => {
const actual =
await vi.importActual<typeof import('node:process')>('node:process');
return {
...actual,
exit: mockedExit,
};
});
const MockedRadioButtonSelect = vi.mocked(RadioButtonSelect);
const MockedMarkdownDisplay = vi.mocked(MarkdownDisplay);
describe('ResumeContextSwitchDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it('renders a string prompt with MarkdownDisplay', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
<ResumeContextSwitchDialog
prompt="Resume here?"
terminalWidth={80}
onConfirm={vi.fn()}
onDecline={vi.fn()}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
isPending: true,
text: 'Resume here?',
terminalWidth: 80,
},
undefined,
);
unmount();
});
it('calls onConfirm when yes is selected', async () => {
const onConfirm = vi.fn();
const onDecline = vi.fn();
const { waitUntilReady, unmount } = await renderWithProviders(
<ResumeContextSwitchDialog
prompt="Resume here?"
terminalWidth={80}
onConfirm={onConfirm}
onDecline={onDecline}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(true);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onDecline).not.toHaveBeenCalled();
expect(mockedExit).not.toHaveBeenCalled();
unmount();
});
it('shows an exit message and exits after no is selected in startup mode', async () => {
vi.useFakeTimers();
const onConfirm = vi.fn();
const onDecline = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ResumeContextSwitchDialog
prompt="Resume here?"
terminalWidth={80}
onConfirm={onConfirm}
onDecline={onDecline}
exitOnDecline={true}
declineExitMessage="Session resume was canceled. Exiting so you can switch to the original folder and rerun the resume command."
exitCode={0}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(false);
});
expect(onDecline).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
await waitFor(() => {
expect(lastFrame()).toContain('Session resume was canceled.');
expect(lastFrame()).toContain('original folder and rerun the resume');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(mockedExit).toHaveBeenCalledWith(0);
unmount();
vi.useRealTimers();
});
it('does not exit for browser decline', async () => {
const onConfirm = vi.fn();
const onDecline = vi.fn();
const { waitUntilReady, unmount } = await renderWithProviders(
<ResumeContextSwitchDialog
prompt={<Text>Resume here?</Text>}
terminalWidth={80}
onConfirm={onConfirm}
onDecline={onDecline}
exitOnDecline={false}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(false);
});
expect(onDecline).toHaveBeenCalledTimes(1);
expect(mockedExit).not.toHaveBeenCalled();
unmount();
});
it('treats escape as decline', async () => {
const onDecline = vi.fn();
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<ResumeContextSwitchDialog
prompt="Resume here?"
terminalWidth={80}
onConfirm={vi.fn()}
onDecline={onDecline}
/>,
);
await waitUntilReady();
await act(async () => {
stdin.write('\u001b[27u');
});
await act(async () => {
await waitUntilReady();
});
expect(onDecline).toHaveBeenCalledTimes(1);
expect(mockedExit).not.toHaveBeenCalled();
unmount();
});
});
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { type ReactNode, useCallback, useState } from 'react';
import * as process from 'node:process';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface ResumeContextSwitchDialogProps {
prompt: ReactNode;
terminalWidth: number;
onConfirm: () => void;
onDecline: () => void;
exitOnDecline?: boolean;
declineExitMessage?: ReactNode;
exitCode?: number;
}
export const ResumeContextSwitchDialog = ({
prompt,
terminalWidth,
onConfirm,
onDecline,
exitOnDecline = false,
declineExitMessage = 'Session resume was canceled. Exiting so you can switch to the original folder and rerun the resume command.',
exitCode = 0,
}: ResumeContextSwitchDialogProps) => {
const [isExiting, setIsExiting] = useState(false);
const handleSelect = useCallback(
(confirmed: boolean) => {
if (confirmed) {
onConfirm();
return;
}
onDecline();
if (!exitOnDecline) {
return;
}
setIsExiting(true);
setTimeout(async () => {
await runExitCleanup();
process.exit(exitCode);
}, 100);
},
[exitCode, exitOnDecline, onConfirm, onDecline],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
handleSelect(false);
return true;
}
return false;
},
{ isActive: !isExiting },
);
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.status.warning}
flexDirection="column"
paddingTop={1}
paddingX={2}
>
{typeof prompt === 'string' ? (
<MarkdownDisplay
isPending={true}
text={prompt}
terminalWidth={terminalWidth}
/>
) : (
prompt
)}
{!isExiting && (
<Box marginTop={1}>
<RadioButtonSelect
items={[
{ label: 'Yes', value: true, key: 'Yes' },
{ label: 'No', value: false, key: 'No' },
]}
onSelect={handleSelect}
/>
</Box>
)}
</Box>
{isExiting && (
<Box marginTop={1}>
{typeof declineExitMessage === 'string' ? (
<Text color={theme.status.warning}>{declineExitMessage}</Text>
) : (
declineExitMessage
)}
</Box>
)}
</Box>
);
};
@@ -125,6 +125,7 @@ const createSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
id: 'session-id',
file: 'session-id',
fileName: 'session-id.json',
sessionPath: '/tmp/test/chats/session-id.json',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messageCount: 1,
@@ -16,6 +16,7 @@ import type { Config } from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import {
formatRelativeTime,
getGlobalChatsDirs,
getSessionFiles,
} from '../../utils/sessionUtils.js';
@@ -220,11 +221,17 @@ const SessionItem = ({
const prefix = isActive ? ' ' : ' ';
let additionalInfo = '';
let matchDisplay = null;
const projectLabel = session.originProjectPath
? path.basename(session.originProjectPath)
: session.projectSlug;
// Add "(current)" label for the current session
if (session.isCurrentSession) {
additionalInfo = ' (current)';
}
if (projectLabel) {
additionalInfo += ` [${projectLabel}]`;
}
// Show match snippets if searching and matches exist
if (
@@ -431,9 +438,8 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
useEffect(() => {
const loadSessions = async () => {
try {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
const sessionData = await getSessionFiles(
chatsDir,
await getGlobalChatsDirs(),
config.getSessionId(),
);
setSessions(sessionData);
@@ -454,12 +460,8 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const loadFullContent = async () => {
if (isSearchMode && !hasLoadedFullContent) {
try {
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const sessionData = await getSessionFiles(
chatsDir,
await getGlobalChatsDirs(),
config.getSessionId(),
{ includeFullContent: true },
);
@@ -111,6 +111,8 @@ export const filterSessions = (
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.originProjectPath?.toLowerCase().includes(lowerQuery) ||
session.projectSlug?.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
@@ -21,6 +21,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
createCache:
actual.createCache ??
((<K, V>() => {
const cache = new Map<K, V>();
return {
clear: () => cache.clear(),
getOrCreate: (key: K, factory: () => V) => {
if (!cache.has(key)) {
cache.set(key, factory());
}
return cache.get(key)!;
},
};
}) as typeof actual.createCache),
getShellConfiguration: vi.fn(),
};
});
@@ -5,31 +5,36 @@
*/
import type React from 'react';
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
duration: string;
sessionId?: string;
}
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
sessionId,
}) => {
const { stats } = useSessionStats();
const config = useConfig();
const { shell } = getShellConfiguration();
const worktreeSettings = config.getWorktreeSettings();
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
const sessionIdForFooter = sessionId ?? stats.sessionId ?? '<session-id>';
const escapedSessionId = escapeShellArg(sessionIdForFooter, shell);
let footer = `Tip: Resume from any folder using gemini --resume ${escapedSessionId} or /resume`;
if (worktreeSettings) {
const escapedWorktreePath = escapeShellArg(worktreeSettings.path, shell);
footer =
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
`Tip: Resume from any folder using gemini --resume ${escapedSessionId}\n` +
`To resume work in this worktree: cd ${escapedWorktreePath} && gemini --resume ${escapedSessionId}\n` +
`To remove manually: git worktree remove ${escapedWorktreePath}`;
}
return (
@@ -23,7 +23,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ To resume this session: gemini --resume test-session
│ Tip: Resume from any folder using gemini --resume test-session or /resume
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -10,6 +10,7 @@ import type {
ThoughtSummary,
ConfirmationRequest,
QuotaStats,
ResumeContextSwitchConfirmationRequest,
LoopDetectionConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
@@ -134,6 +135,7 @@ export interface UIState {
commandContext: CommandContext;
commandConfirmationRequest: ConfirmationRequest | null;
authConsentRequest: ConfirmationRequest | null;
resumeContextSwitchConfirmationRequest: ResumeContextSwitchConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
permissionConfirmationRequest: PermissionConfirmationRequest | null;
@@ -12,8 +12,7 @@ import {
convertSessionToHistoryFormats,
} from './useSessionBrowser.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
import { type SessionInfo } from '../../utils/sessionUtils.js';
import {
type Config,
type ConversationRecord,
@@ -42,6 +41,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
createCache:
actual.createCache ??
((<K, V>() => {
const cache = new Map<K, V>();
return {
clear: () => cache.clear(),
getOrCreate: (key: K, factory: () => V) => {
if (!cache.has(key)) {
cache.set(key, factory());
}
return cache.get(key)!;
},
};
}) as typeof actual.createCache),
uiTelemetryService: {
clear: vi.fn(),
hydrate: vi.fn(),
@@ -49,45 +62,36 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
const MOCKED_SESSION_ID = 'test-session-123';
const MOCKED_CURRENT_SESSION_ID = 'current-session-id';
const MOCKED_SESSION_PATH =
'/test/project/temp/chats/session-2025-01-01-test-session-123.json';
describe('useSessionBrowser', () => {
const mockedFs = vi.mocked(fs);
const mockedPath = vi.mocked(path);
const mockedGetSessionFiles = vi.mocked(getSessionFiles);
const mockConfig = {
storage: {
getProjectTempDir: vi.fn(),
},
setSessionId: vi.fn(),
getSessionId: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue({
getChatRecordingService: vi.fn().mockReturnValue({
deleteSession: vi.fn(),
deleteSessionByPath: vi.fn(),
}),
}),
} as unknown as Config;
const mockOnLoadHistory = vi.fn();
const mockOnLoadHistory = vi.fn().mockResolvedValue(true);
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
mockedPath.join.mockImplementation((...args) => args.join('/'));
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
MOCKED_PROJECT_TEMP_DIR,
);
vi.mocked(mockConfig.getSessionId).mockReturnValue(
MOCKED_CURRENT_SESSION_ID,
);
mockOnLoadHistory.mockResolvedValue(true);
});
it('should successfully resume a session', async () => {
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
const mockConversation: ConversationRecord = {
sessionId: 'existing-session-456',
messages: [{ type: 'user', content: 'Hello' } as MessageRecord],
@@ -95,9 +99,8 @@ describe('useSessionBrowser', () => {
const mockSession = {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
sessionPath: MOCKED_SESSION_PATH,
} as SessionInfo;
mockedGetSessionFiles.mockResolvedValue([mockSession]);
mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation));
const { result } = await renderHook(() =>
@@ -107,10 +110,7 @@ describe('useSessionBrowser', () => {
await act(async () => {
await result.current.handleResumeSession(mockSession);
});
expect(mockedFs.readFile).toHaveBeenCalledWith(
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
'utf8',
);
expect(mockedFs.readFile).toHaveBeenCalledWith(MOCKED_SESSION_PATH, 'utf8');
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
'existing-session-456',
);
@@ -120,10 +120,9 @@ describe('useSessionBrowser', () => {
});
it('should handle file read error', async () => {
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
const mockSession = {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
sessionPath: MOCKED_SESSION_PATH,
} as SessionInfo;
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
@@ -144,10 +143,9 @@ describe('useSessionBrowser', () => {
});
it('should handle JSON parse error', async () => {
const MOCKED_FILENAME = 'invalid.json';
const mockSession = {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
sessionPath: MOCKED_SESSION_PATH,
} as SessionInfo;
mockedFs.readFile.mockResolvedValue('invalid json');
+29 -13
View File
@@ -30,7 +30,8 @@ export const useSessionBrowser = (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
resumedSessionData: ResumedSessionData,
) => Promise<void>,
source?: 'startup' | 'browser',
) => Promise<boolean>,
) => {
const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false);
@@ -51,14 +52,13 @@ export const useSessionBrowser = (
handleResumeSession: useCallback(
async (session: SessionInfo) => {
try {
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const fileName = session.fileName;
const originalFilePath = path.join(chatsDir, fileName);
const originalFilePath =
session.sessionPath ??
path.join(
config.storage.getProjectTempDir(),
'chats',
session.fileName,
);
// Load up the conversation.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -68,24 +68,33 @@ export const useSessionBrowser = (
// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
const currentSessionId = config.getSessionId();
config.setSessionId(existingSessionId);
uiTelemetryService.hydrate(conversation);
const resumedSessionData = {
conversation,
filePath: originalFilePath,
originProjectPath:
session.originProjectPath ?? conversation.originProjectPath,
};
// We've loaded it; tell the UI about it.
setIsSessionBrowserOpen(false);
const historyData = convertSessionToHistoryFormats(
conversation.messages,
);
await onLoadHistory(
const didResume = await onLoadHistory(
historyData.uiHistory,
convertSessionToClientHistory(conversation.messages),
resumedSessionData,
'browser',
);
if (!didResume) {
config.setSessionId(currentSessionId);
return;
}
// We've loaded it; tell the UI about it.
setIsSessionBrowserOpen(false);
} catch (error) {
coreEvents.emitFeedback('error', 'Error resuming session:', error);
setIsSessionBrowserOpen(false);
@@ -108,7 +117,14 @@ export const useSessionBrowser = (
.getGeminiClient()
?.getChatRecordingService();
if (chatRecordingService) {
chatRecordingService.deleteSession(session.file);
if (session.sessionPath) {
chatRecordingService.deleteSessionByPath(
session.sessionPath,
session.id,
);
} else {
chatRecordingService.deleteSession(session.file);
}
}
} catch (error) {
coreEvents.emitFeedback('error', 'Error deleting session:', error);
@@ -25,6 +25,7 @@ describe('useSessionResume', () => {
const mockConfig = {
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getProjectRoot: vi.fn().mockReturnValue('/current/project'),
};
const createMockHistoryManager = (): UseHistoryManagerReturn => ({
@@ -48,6 +49,8 @@ describe('useSessionResume', () => {
setQuittingMessages: mockSetQuittingMessages,
resumedSessionData: undefined,
isAuthenticating: false,
confirmResumeContextSwitch: undefined,
canResumeOnStartup: undefined,
});
beforeEach(() => {
@@ -261,6 +264,77 @@ describe('useSessionResume', () => {
expect(mockAddDirectories).not.toHaveBeenCalled();
});
it('should confirm before resuming from a different project root', async () => {
const confirmResumeContextSwitch = vi.fn().mockResolvedValue(true);
const { result } = await renderHook(() =>
useSessionResume({
...getDefaultProps(),
confirmResumeContextSwitch,
}),
);
const resumedData: ResumedSessionData = {
conversation: {
sessionId: 'test-123',
projectHash: 'project-123',
originProjectPath: '/original/project',
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T01:00:00Z',
messages: [] as MessageRecord[],
},
filePath: '/path/to/session.json',
originProjectPath: '/original/project',
};
await act(async () => {
await result.current.loadHistoryForResume(
[],
[],
resumedData,
'startup',
);
});
expect(confirmResumeContextSwitch).toHaveBeenCalledWith({
source: 'startup',
sessionId: 'test-123',
currentProjectPath: '/current/project',
originProjectPath: '/original/project',
});
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
});
it('should abort resume when context switch confirmation is rejected', async () => {
const confirmResumeContextSwitch = vi.fn().mockResolvedValue(false);
const { result } = await renderHook(() =>
useSessionResume({
...getDefaultProps(),
confirmResumeContextSwitch,
}),
);
const resumedData: ResumedSessionData = {
conversation: {
sessionId: 'test-123',
projectHash: 'project-123',
originProjectPath: '/original/project',
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T01:00:00Z',
messages: [] as MessageRecord[],
},
filePath: '/path/to/session.json',
originProjectPath: '/original/project',
};
await act(async () => {
await result.current.loadHistoryForResume([], [], resumedData);
});
expect(confirmResumeContextSwitch).toHaveBeenCalled();
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
});
});
describe('callback stability', () => {
@@ -341,6 +415,48 @@ describe('useSessionResume', () => {
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
});
it('should wait for startup blockers to clear before auto-resuming', async () => {
const conversation: ConversationRecord = {
sessionId: 'auto-resume-blocked',
projectHash: 'project-123',
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T01:00:00Z',
messages: [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Blocked until startup is ready',
type: 'user',
},
] as MessageRecord[],
};
let startupReady = false;
const { rerender } = await renderHook(() =>
useSessionResume({
...getDefaultProps(),
resumedSessionData: {
conversation,
filePath: '/path/to/session.json',
},
canResumeOnStartup: () => startupReady,
}),
);
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
await act(async () => {
startupReady = true;
rerender();
});
await waitFor(() => {
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
});
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
});
it('should not resume when Gemini client is not initialized', async () => {
const conversation: ConversationRecord = {
sessionId: 'auto-resume-123',
+50 -3
View File
@@ -15,6 +15,16 @@ import type { Part } from '@google/genai';
import type { HistoryItemWithoutId } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
import { isSameProjectPath } from '../../utils/sessionUtils.js';
export type ResumeSource = 'startup' | 'browser';
export interface ResumeContextSwitchConfirmation {
source: ResumeSource;
sessionId: string;
currentProjectPath: string;
originProjectPath: string;
}
interface UseSessionResumeParams {
config: Config;
@@ -24,6 +34,10 @@ interface UseSessionResumeParams {
setQuittingMessages: (messages: null) => void;
resumedSessionData?: ResumedSessionData;
isAuthenticating: boolean;
canResumeOnStartup?: () => boolean;
confirmResumeContextSwitch?: (
details: ResumeContextSwitchConfirmation,
) => Promise<boolean>;
}
/**
@@ -39,6 +53,8 @@ export function useSessionResume({
setQuittingMessages,
resumedSessionData,
isAuthenticating,
canResumeOnStartup = () => true,
confirmResumeContextSwitch,
}: UseSessionResumeParams) {
const [isResuming, setIsResuming] = useState(false);
@@ -56,10 +72,31 @@ export function useSessionResume({
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
resumedData: ResumedSessionData,
) => {
source: ResumeSource = 'browser',
): Promise<boolean> => {
// Wait for the client.
if (!isGeminiClientInitialized) {
return;
return false;
}
const originProjectPath =
resumedData.originProjectPath ??
resumedData.conversation.originProjectPath;
const currentProjectPath = config.getProjectRoot();
const requiresConfirmation =
!!originProjectPath &&
!isSameProjectPath(originProjectPath, currentProjectPath);
if (requiresConfirmation && confirmResumeContextSwitch) {
const confirmed = await confirmResumeContextSwitch({
source,
sessionId: resumedData.conversation.sessionId,
currentProjectPath,
originProjectPath,
});
if (!confirmed) {
return false;
}
}
setIsResuming(true);
@@ -85,17 +122,24 @@ export function useSessionResume({
// Give the history to the Gemini client.
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
return true;
} catch (error) {
coreEvents.emitFeedback(
'error',
'Failed to resume session. Please try again.',
error,
);
return false;
} finally {
setIsResuming(false);
}
},
[config, isGeminiClientInitialized, setQuittingMessages],
[
config,
confirmResumeContextSwitch,
isGeminiClientInitialized,
setQuittingMessages,
],
);
// Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive).
@@ -105,6 +149,7 @@ export function useSessionResume({
if (
resumedSessionData &&
!isAuthenticating &&
canResumeOnStartup() &&
isGeminiClientInitialized &&
!hasLoadedResumedSession.current
) {
@@ -116,11 +161,13 @@ export function useSessionResume({
historyData.uiHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
'startup',
);
}
}, [
resumedSessionData,
isAuthenticating,
canResumeOnStartup,
isGeminiClientInitialized,
loadHistoryForResume,
]);
@@ -69,9 +69,7 @@ export const DefaultAppLayout: React.FC = () => {
<Notifications />
<CopyModeWarning />
{uiState.customDialog ? (
uiState.customDialog
) : uiState.dialogsVisible ? (
{uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
+10
View File
@@ -226,6 +226,7 @@ export type HistoryItemModel = HistoryItemBase & {
export type HistoryItemQuit = HistoryItemBase & {
type: 'quit';
duration: string;
sessionId?: string;
};
export type HistoryItemToolGroup = HistoryItemBase & {
@@ -495,6 +496,15 @@ export interface ConfirmationRequest {
onConfirm: (confirm: boolean) => void;
}
export interface ResumeContextSwitchConfirmationRequest {
prompt: ReactNode;
onConfirm: () => void;
onDecline: () => void;
exitOnDecline?: boolean;
declineExitMessage?: ReactNode;
exitCode?: number;
}
export interface LoopDetectionConfirmationRequest {
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
}
+119 -20
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
SessionSelector,
extractFirstUserMessage,
@@ -17,6 +17,7 @@ import {
type Config,
type MessageRecord,
} from '@google/gemini-cli-core';
import { Storage } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
@@ -24,18 +25,34 @@ import { randomUUID } from 'node:crypto';
describe('SessionSelector', () => {
let tmpDir: string;
let config: Config;
const createChatsDir = async (
slug = 'project-a',
projectRoot = `/workspace/${slug}`,
) => {
const projectTempDir = path.join(tmpDir, slug);
const chatsDir = path.join(projectTempDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
await fs.writeFile(
path.join(projectTempDir, '.project_root'),
projectRoot,
'utf8',
);
return chatsDir;
};
beforeEach(async () => {
// Create a temporary directory for testing
tmpDir = path.join(process.cwd(), '.tmp-test-sessions');
await fs.mkdir(tmpDir, { recursive: true });
vi.spyOn(Storage, 'getGlobalTempDir').mockReturnValue(tmpDir);
// Mock config
config = {
storage: {
getProjectTempDir: () => tmpDir,
getProjectTempDir: () => path.join(tmpDir, 'current-project'),
},
getSessionId: () => 'current-session-id',
getProjectRoot: () => '/workspace/current-project',
} as Partial<Config> as Config;
});
@@ -46,6 +63,7 @@ describe('SessionSelector', () => {
} catch (_error) {
// Ignore cleanup errors
}
vi.restoreAllMocks();
});
it('should resolve session by UUID', async () => {
@@ -53,8 +71,7 @@ describe('SessionSelector', () => {
const sessionId2 = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
const session1 = {
sessionId: sessionId1,
@@ -119,8 +136,7 @@ describe('SessionSelector', () => {
const sessionId2 = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
const session1 = {
sessionId: sessionId1,
@@ -183,8 +199,7 @@ describe('SessionSelector', () => {
const sessionId2 = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
const session1 = {
sessionId: sessionId1,
@@ -243,8 +258,7 @@ describe('SessionSelector', () => {
const sessionId = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
const session = {
sessionId,
@@ -281,8 +295,7 @@ describe('SessionSelector', () => {
const sessionId = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
const sessionOriginal = {
sessionId,
@@ -345,8 +358,7 @@ describe('SessionSelector', () => {
const sessionId1 = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
const session1 = {
sessionId: sessionId1,
@@ -410,8 +422,7 @@ describe('SessionSelector', () => {
const sessionIdSystemOnly = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
// Session with user message - should be listed
const sessionWithUser = {
@@ -479,8 +490,7 @@ describe('SessionSelector', () => {
const sessionIdGeminiOnly = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
// Session with only gemini message - should be listed
const sessionGeminiOnly = {
@@ -519,8 +529,7 @@ describe('SessionSelector', () => {
const subagentSessionId = randomUUID();
// Create test session files
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const chatsDir = await createChatsDir();
// Main session - should be listed
const mainSession = {
@@ -579,6 +588,96 @@ describe('SessionSelector', () => {
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(mainSessionId);
});
it('should resolve sessions across projects and report origin mismatch', async () => {
const sessionId = randomUUID();
const chatsDir = await createChatsDir(
'original-project',
'/workspace/original-project',
);
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
),
JSON.stringify(
{
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:30:00.000Z',
messages: [
{
type: 'user',
content: 'Cross project resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
},
null,
2,
),
);
const sessionSelector = new SessionSelector(config);
const result = await sessionSelector.resolveSession(sessionId);
expect(result.originProjectPath).toBe('/workspace/original-project');
expect(result.isOriginProjectMismatch).toBe(true);
expect(result.sessionData.sessionId).toBe(sessionId);
});
it('ignores symlinked project root markers', async () => {
if (process.platform === 'win32') {
return;
}
const sessionId = randomUUID();
const chatsDir = await createChatsDir(
'symlink-project',
'/workspace/symlink-project',
);
const projectTempDir = path.dirname(chatsDir);
const markerPath = path.join(projectTempDir, '.project_root');
const linkedPath = path.join(tmpDir, 'linked-secret.txt');
await fs.writeFile(linkedPath, '/workspace/linked-secret', 'utf8');
await fs.rm(markerPath);
await fs.symlink(linkedPath, markerPath);
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
),
JSON.stringify(
{
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:30:00.000Z',
messages: [
{
type: 'user',
content: 'Ignore symlinked origin marker',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
],
},
null,
2,
),
);
const sessionSelector = new SessionSelector(config);
const result = await sessionSelector.resolveSession(sessionId);
expect(result.originProjectPath).toBeUndefined();
expect(result.isOriginProjectMismatch).toBe(false);
});
});
describe('extractFirstUserMessage', () => {
+106 -21
View File
@@ -12,12 +12,15 @@ import {
type Config,
type ConversationRecord,
type MessageRecord,
Storage,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
import { MessageType, type HistoryItemWithoutId } from '../ui/types.js';
const PROJECT_ROOT_FILE = '.project_root';
/**
* Constant for the resume "latest" identifier.
* Used when --resume is passed without a value to select the most recent session.
@@ -48,10 +51,7 @@ export class SessionError extends Error {
* Creates an error for when no sessions exist for the current project.
*/
static noSessionsFound(): SessionError {
return new SessionError(
'NO_SESSIONS_FOUND',
'No previous sessions found for this project.',
);
return new SessionError('NO_SESSIONS_FOUND', 'No previous sessions found.');
}
/**
@@ -93,6 +93,8 @@ export interface SessionInfo {
file: string;
/** Full filename including .json extension */
fileName: string;
/** Absolute path to the session file */
sessionPath?: string;
/** ISO timestamp when session started */
startTime: string;
/** Total number of messages in the session */
@@ -105,6 +107,10 @@ export interface SessionInfo {
firstUserMessage: string;
/** Whether this is the currently active session */
isCurrentSession: boolean;
/** Original project root where the session was created */
originProjectPath?: string;
/** Project storage slug that owns this session */
projectSlug?: string;
/** Display index in the list */
index: number;
/** AI-generated summary of the session (if available) */
@@ -136,6 +142,9 @@ export interface SessionSelectionResult {
sessionPath: string;
sessionData: ConversationRecord;
displayInfo: string;
originProjectPath?: string;
projectSlug?: string;
isOriginProjectMismatch: boolean;
}
/**
@@ -238,6 +247,66 @@ export interface GetSessionOptions {
includeFullContent?: boolean;
}
const normalizePathForComparison = (input: string): string => {
let normalized = path.resolve(input);
if (process.platform === 'win32') {
normalized = normalized.toLowerCase();
}
return normalized;
};
export const isSameProjectPath = (left?: string, right?: string): boolean => {
if (!left || !right) {
return false;
}
return normalizePathForComparison(left) === normalizePathForComparison(right);
};
const getProjectTempDirForSession = (sessionPath: string): string =>
path.dirname(path.dirname(sessionPath));
const readProjectRootMarker = async (
projectTempDir: string,
): Promise<string | undefined> => {
try {
const markerPath = path.join(projectTempDir, PROJECT_ROOT_FILE);
const markerStats = await fs.lstat(markerPath);
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
return undefined;
}
const projectRoot = (await fs.readFile(markerPath, 'utf8')).trim();
return projectRoot || undefined;
} catch {
return undefined;
}
};
export const resolveSessionOriginProjectPath = async (
sessionPath: string,
conversation?: Pick<ConversationRecord, 'originProjectPath'>,
): Promise<string | undefined> =>
conversation?.originProjectPath ??
(await readProjectRootMarker(getProjectTempDirForSession(sessionPath)));
export const getGlobalChatsDirs = async (): Promise<string[]> => {
try {
const entries = await fs.readdir(Storage.getGlobalTempDir(), {
withFileTypes: true,
});
return entries
.filter((entry) => entry.isDirectory() && entry.name !== 'bin')
.map((entry) =>
path.join(Storage.getGlobalTempDir(), entry.name, 'chats'),
);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return [];
}
throw error;
}
};
/**
* Loads all session files (including corrupted ones) from the chats directory.
* @returns Array of session file entries, with sessionInfo null for corrupted files
@@ -256,6 +325,8 @@ export const getAllSessionFiles = async (
const sessionPromises = sessionFiles.map(
async (file): Promise<SessionFileEntry> => {
const filePath = path.join(chatsDir, file);
const projectTempDir = path.dirname(chatsDir);
const projectSlug = path.basename(projectTempDir);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const content: ConversationRecord = JSON.parse(
@@ -287,8 +358,12 @@ export const getAllSessionFiles = async (
const firstUserMessage = extractFirstUserMessage(content.messages);
const isCurrentSession = currentSessionId
? file.includes(currentSessionId.slice(0, 8))
? content.sessionId === currentSessionId
: false;
const originProjectPath = await resolveSessionOriginProjectPath(
filePath,
content,
);
let fullContent: string | undefined;
let messages:
@@ -312,6 +387,7 @@ export const getAllSessionFiles = async (
id: content.sessionId,
file: file.replace('.json', ''),
fileName: file,
sessionPath: filePath,
startTime: content.startTime,
lastUpdated: content.lastUpdated,
messageCount: content.messages.length,
@@ -320,6 +396,8 @@ export const getAllSessionFiles = async (
: firstUserMessage,
firstUserMessage,
isCurrentSession,
originProjectPath,
projectSlug,
index: 0, // Will be set after sorting valid sessions
summary: content.summary,
fullContent,
@@ -350,18 +428,18 @@ export const getAllSessionFiles = async (
* Corrupted files are automatically filtered out.
*/
export const getSessionFiles = async (
chatsDir: string,
chatsDir: string | string[],
currentSessionId?: string,
options: GetSessionOptions = {},
): Promise<SessionInfo[]> => {
const allFiles = await getAllSessionFiles(
chatsDir,
currentSessionId,
options,
const chatsDirs = Array.isArray(chatsDir) ? chatsDir : [chatsDir];
const allFiles = await Promise.all(
chatsDirs.map((dir) => getAllSessionFiles(dir, currentSessionId, options)),
);
const flattenedFiles = allFiles.flat();
// Filter out corrupted files and extract SessionInfo
const validSessions = allFiles
const validSessions = flattenedFiles
.filter(
(entry): entry is { fileName: string; sessionInfo: SessionInfo } =>
entry.sessionInfo !== null,
@@ -402,14 +480,13 @@ export class SessionSelector {
constructor(private config: Config) {}
/**
* Lists all available sessions for the current project.
* Lists all available sessions across all projects.
*/
async listSessions(): Promise<SessionInfo[]> {
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
return getSessionFiles(
await getGlobalChatsDirs(),
this.config.getSessionId(),
);
return getSessionFiles(chatsDir, this.config.getSessionId());
}
/**
@@ -507,17 +584,22 @@ export class SessionSelector {
private async selectSession(
sessionInfo: SessionInfo,
): Promise<SessionSelectionResult> {
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
);
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
if (!sessionInfo.sessionPath) {
throw new Error(`Failed to locate session file for ${sessionInfo.id}.`);
}
const sessionPath = sessionInfo.sessionPath;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const sessionData: ConversationRecord = JSON.parse(
await fs.readFile(sessionPath, 'utf8'),
);
const originProjectPath =
sessionInfo.originProjectPath ??
(await resolveSessionOriginProjectPath(sessionPath, sessionData));
const isOriginProjectMismatch =
!!originProjectPath &&
!isSameProjectPath(originProjectPath, this.config.getProjectRoot());
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
@@ -525,6 +607,9 @@ export class SessionSelector {
sessionPath,
sessionData,
displayInfo,
originProjectPath,
projectSlug: sessionInfo.projectSlug,
isOriginProjectMismatch,
};
} catch (error) {
throw new Error(
+4 -6
View File
@@ -72,7 +72,7 @@ describe('listSessions', () => {
// Assert
expect(mockListSessions).toHaveBeenCalledOnce();
expect(mocks.writeToStdout).toHaveBeenCalledWith(
'No previous sessions found for this project.',
'No previous sessions found.',
);
});
@@ -131,7 +131,7 @@ describe('listSessions', () => {
// Check that the header was displayed
expect(mocks.writeToStdout).toHaveBeenCalledWith(
'\nAvailable sessions for this project (3):\n',
'\nAvailable sessions (3):\n',
);
// Check that each session was logged
@@ -286,7 +286,7 @@ describe('listSessions', () => {
// Assert
expect(mocks.writeToStdout).toHaveBeenCalledWith(
'\nAvailable sessions for this project (1):\n',
'\nAvailable sessions (1):\n',
);
expect(mocks.writeToStdout).toHaveBeenCalledWith(
expect.stringContaining('1. Only session'),
@@ -379,9 +379,7 @@ describe('deleteSession', () => {
// Assert
expect(mockListSessions).toHaveBeenCalledOnce();
expect(mocks.writeToStderr).toHaveBeenCalledWith(
'No sessions found for this project.',
);
expect(mocks.writeToStderr).toHaveBeenCalledWith('No sessions found.');
expect(mockDeleteSession).not.toHaveBeenCalled();
});
+15 -7
View File
@@ -25,13 +25,11 @@ export async function listSessions(config: Config): Promise<void> {
const sessions = await sessionSelector.listSessions();
if (sessions.length === 0) {
writeToStdout('No previous sessions found for this project.');
writeToStdout('No previous sessions found.');
return;
}
writeToStdout(
`\nAvailable sessions for this project (${sessions.length}):\n`,
);
writeToStdout(`\nAvailable sessions (${sessions.length}):\n`);
sessions
.sort(
@@ -45,8 +43,11 @@ export async function listSessions(config: Config): Promise<void> {
session.displayName.length > 100
? session.displayName.slice(0, 97) + '...'
: session.displayName;
const originSuffix = session.originProjectPath
? ` @ ${session.originProjectPath}`
: '';
writeToStdout(
` ${index + 1}. ${title} (${time}${current}) [${session.id}]\n`,
` ${index + 1}. ${title} (${time}${current}) [${session.id}]${originSuffix}\n`,
);
});
}
@@ -59,7 +60,7 @@ export async function deleteSession(
const sessions = await sessionSelector.listSessions();
if (sessions.length === 0) {
writeToStderr('No sessions found for this project.');
writeToStderr('No sessions found.');
return;
}
@@ -97,7 +98,14 @@ export async function deleteSession(
try {
// Use ChatRecordingService to delete the session
const chatRecordingService = new ChatRecordingService(config);
chatRecordingService.deleteSession(sessionToDelete.file);
if (sessionToDelete.sessionPath) {
chatRecordingService.deleteSessionByPath(
sessionToDelete.sessionPath,
sessionToDelete.id,
);
} else {
chatRecordingService.deleteSession(sessionToDelete.file);
}
const time = formatRelativeTime(sessionToDelete.lastUpdated);
writeToStdout(
@@ -96,6 +96,8 @@ export type MessageRecord = BaseMessageRecord & ConversationRecordExtra;
export interface ConversationRecord {
sessionId: string;
projectHash: string;
/** Original project root where this session was first created */
originProjectPath?: string;
startTime: string;
lastUpdated: string;
messages: MessageRecord[];
@@ -112,6 +114,7 @@ export interface ConversationRecord {
export interface ResumedSessionData {
conversation: ConversationRecord;
filePath: string;
originProjectPath?: string;
}
/**
@@ -164,6 +167,10 @@ export class ChatRecordingService {
// Update the session ID in the existing file
this.updateConversation((conversation) => {
conversation.sessionId = this.sessionId;
conversation.originProjectPath =
conversation.originProjectPath ??
resumedSessionData.originProjectPath ??
this.context.config.getProjectRoot();
});
// Clear any cached data to force fresh reads
@@ -191,6 +198,7 @@ export class ChatRecordingService {
this.writeConversation({
sessionId: this.sessionId,
projectHash: this.projectHash,
originProjectPath: this.context.config.getProjectRoot(),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
@@ -603,21 +611,26 @@ export class ChatRecordingService {
const shortId = this.deriveShortId(sessionIdOrBasename);
if (!fs.existsSync(chatsDir)) {
return; // Nothing to delete
}
const matchingFiles = this.getMatchingSessionFiles(chatsDir, shortId);
for (const file of matchingFiles) {
this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
}
this.deleteMatchingSessionFiles(chatsDir, tempDir, shortId);
} catch (error) {
debugLogger.error('Error deleting session file.', error);
throw error;
}
}
/**
* Deletes a session file by absolute path.
*/
deleteSessionByPath(sessionPath: string, sessionId?: string): void {
const tempDir = path.dirname(path.dirname(sessionPath));
const chatsDir = path.dirname(sessionPath);
const shortId = this.deriveShortId(
sessionId ?? path.basename(sessionPath, '.json'),
);
this.deleteMatchingSessionFiles(chatsDir, tempDir, shortId);
}
/**
* Derives an 8-character shortId from a sessionId, filename, or basename.
*/
@@ -640,6 +653,24 @@ export class ChatRecordingService {
return shortId;
}
/**
* Deletes every session file and artifact that matches the provided shortId.
*/
private deleteMatchingSessionFiles(
chatsDir: string,
tempDir: string,
shortId: string,
): void {
if (!fs.existsSync(chatsDir)) {
return;
}
const matchingFiles = this.getMatchingSessionFiles(chatsDir, shortId);
for (const file of matchingFiles) {
this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
}
}
/**
* Finds all session files matching the pattern session-*-<shortId>.json
*/