mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 17:51:04 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56d5925a34 | |||
| b6fe265d0c | |||
| 81006a7f82 | |||
| 6c119110e4 |
+16
-16
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user