mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat(cli): integrate TrajectoryProvider into /resume UI
This commit is contained in:
@@ -19,12 +19,16 @@ import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
themeError: string | null;
|
||||
shouldOpenAuthDialog: boolean;
|
||||
geminiMdFileCount: number;
|
||||
recentExternalSession?: { prefix: string; id: string; displayName?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,11 +64,34 @@ export async function initializeApp(
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
}
|
||||
|
||||
// Check for recent external sessions to prompt for resume
|
||||
let recentExternalSession:
|
||||
| { prefix: string; id: string; displayName?: string }
|
||||
| undefined;
|
||||
if (config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
const extensionLoader = (config as any)._extensionLoader;
|
||||
if (extensionLoader) {
|
||||
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
||||
const recent =
|
||||
await extensionLoader.getRecentExternalSession(workspaceUri);
|
||||
if (recent) {
|
||||
recentExternalSession = {
|
||||
prefix: recent.prefix,
|
||||
id: recent.id,
|
||||
displayName: recent.displayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
return {
|
||||
authError,
|
||||
accountSuspensionInfo,
|
||||
themeError,
|
||||
shouldOpenAuthDialog,
|
||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||
recentExternalSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
generateSummary(config).catch((e) => {
|
||||
debugLogger.warn('Background summary generation failed:', e);
|
||||
});
|
||||
|
||||
// Show handoff prompt for recent external sessions
|
||||
if (initializationResult.recentExternalSession) {
|
||||
const { prefix, id, displayName } =
|
||||
initializationResult.recentExternalSession;
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `🛸 You have a recent session in ${displayName || 'an external tool'}. Type "/resume ${prefix}${id}" to bring it into the terminal.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
})();
|
||||
registerCleanup(async () => {
|
||||
// Turn off mouse scroll.
|
||||
|
||||
@@ -8,10 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { agentsCommand } from './agentsCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { Storage } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
|
||||
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
vi.mock('../../utils/agentSettings.js', () => ({
|
||||
enableAgent: vi.fn(),
|
||||
@@ -105,40 +109,34 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
it('should reload the agent registry when refresh subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
expect(reloadCommand).toBeDefined();
|
||||
expect(refreshCommand).toBeDefined();
|
||||
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading agent registry...',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content: 'Agents refreshed successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
it('should show an error if agent registry is not available during refresh', async () => {
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -462,4 +460,56 @@ describe('agentsCommand', () => {
|
||||
expect(completions).toEqual(['agent1', 'agent2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import sub-command', () => {
|
||||
it('should import an agent with correct tool mapping', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`---
|
||||
name: test-claude-agent
|
||||
tools: Read, Glob, Grep, Bash
|
||||
model: sonnet
|
||||
---
|
||||
System prompt content`);
|
||||
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.spyOn(Storage, 'getUserAgentsDir').mockReturnValue('/mock/agents');
|
||||
|
||||
const importCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'import',
|
||||
);
|
||||
expect(importCommand).toBeDefined();
|
||||
|
||||
const result = await importCommand!.action!(
|
||||
mockContext,
|
||||
'/path/to/claude.md',
|
||||
);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/path/to/claude.md', 'utf-8');
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/test-claude-agent\.md$/),
|
||||
expect.stringContaining('tools:\n - read_file\n - run_command\n'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
"Successfully imported agent 'test-claude-agent'",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error if no file path provided', async () => {
|
||||
const importCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'import',
|
||||
);
|
||||
const result = await importCommand!.action!(mockContext, ' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /agents import <file-path>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fsPromises from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
decodeTagName,
|
||||
type MessageActionReturn,
|
||||
INITIAL_HISTORY_LENGTH,
|
||||
type ConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
@@ -174,8 +176,119 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
const checkpoint = await logger.loadCheckpoint(tag);
|
||||
const conversation = checkpoint.history;
|
||||
|
||||
let conversation: readonly Content[] = [];
|
||||
let authType: string | undefined;
|
||||
|
||||
const loadExternalTrajectory = async (
|
||||
prefix: string,
|
||||
id: string,
|
||||
): Promise<Content[] | null> => {
|
||||
let record: ConversationRecord | null = null;
|
||||
if (config && config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
||||
const extensions = (config as any)._extensionLoader?.getExtensions
|
||||
? (config as any)._extensionLoader.getExtensions()
|
||||
: [];
|
||||
for (const extension of extensions) {
|
||||
if (
|
||||
extension.trajectoryProviderModule &&
|
||||
extension.trajectoryProviderModule.prefix === prefix
|
||||
) {
|
||||
try {
|
||||
record = await extension.trajectoryProviderModule.loadSession(id);
|
||||
if (record) break;
|
||||
} catch (_err) {
|
||||
// Ignore failure
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
||||
}
|
||||
if (!record) return null;
|
||||
|
||||
const conv: Content[] = [];
|
||||
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
|
||||
conv.push({ role: 'user', parts: [{ text: '' }] });
|
||||
|
||||
for (const m of record.messages) {
|
||||
if (m.type === 'user') {
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content.map((c: string | Part) =>
|
||||
typeof c === 'string' ? { text: c } : { text: c.text || '' },
|
||||
)
|
||||
: [{ text: '' }];
|
||||
conv.push({ role: 'user', parts });
|
||||
} else if (m.type === 'gemini') {
|
||||
// 1. Model turn: Text + Function Calls
|
||||
const modelParts: Part[] = [];
|
||||
if (Array.isArray(m.content)) {
|
||||
m.content.forEach((c: string | Part) => {
|
||||
modelParts.push(
|
||||
typeof c === 'string' ? { text: c } : { text: c.text || '' },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
conv.push({ role: 'model', parts: modelParts });
|
||||
|
||||
// 2. User turn: Function Responses (if any)
|
||||
if (
|
||||
m.toolCalls &&
|
||||
m.toolCalls.some((tc: { result?: unknown }) => tc.result)
|
||||
) {
|
||||
const responseParts: Part[] = [];
|
||||
for (const tc of m.toolCalls) {
|
||||
if (tc.result) {
|
||||
responseParts.push({
|
||||
functionResponse: {
|
||||
name: tc.name,
|
||||
response: { result: tc.result },
|
||||
id: tc.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (responseParts.length > 0) {
|
||||
conv.push({ role: 'user', parts: responseParts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return conv;
|
||||
};
|
||||
|
||||
// Check if tag format is prefix:id
|
||||
const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
|
||||
if (prefixMatch) {
|
||||
const prefix = prefixMatch[1] + ':';
|
||||
const id = prefixMatch[2];
|
||||
const externalConv = await loadExternalTrajectory(prefix, id);
|
||||
if (!externalConv) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `No external session found with prefix ${prefix} and id: ${id}`,
|
||||
};
|
||||
}
|
||||
conversation = externalConv;
|
||||
} else {
|
||||
const checkpoint = await logger.loadCheckpoint(tag);
|
||||
if (checkpoint.history.length > 0) {
|
||||
conversation = checkpoint.history;
|
||||
authType = checkpoint.authType;
|
||||
}
|
||||
}
|
||||
|
||||
if (conversation.length === 0) {
|
||||
return {
|
||||
@@ -186,15 +299,11 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
const currentAuthType = config?.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
checkpoint.authType &&
|
||||
currentAuthType &&
|
||||
checkpoint.authType !== currentAuthType
|
||||
) {
|
||||
if (authType && currentAuthType && authType !== currentAuthType) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
|
||||
content: `Cannot resume chat. It was saved with a different authentication method (${authType}) than the current one (${currentAuthType}).`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,11 +315,34 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) {
|
||||
const text =
|
||||
item.parts
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parts = item.parts as Array<{
|
||||
text?: string;
|
||||
functionCall?: { name: string };
|
||||
functionResponse?: { name: string };
|
||||
}>;
|
||||
let text =
|
||||
parts
|
||||
?.filter((m) => !!m.text)
|
||||
.map((m) => m.text)
|
||||
.join('') || '';
|
||||
|
||||
const toolCalls = parts?.filter((p) => !!p.functionCall);
|
||||
if (toolCalls?.length) {
|
||||
const calls = toolCalls
|
||||
.map((tc) => `[Tool Call: ${tc.functionCall?.name}]`)
|
||||
.join('\n');
|
||||
text = text ? `${text}\n${calls}` : calls;
|
||||
}
|
||||
|
||||
const toolResponses = parts?.filter((p) => !!p.functionResponse);
|
||||
if (toolResponses?.length) {
|
||||
const responses = toolResponses
|
||||
.map((tr) => `[Tool Result: ${tr.functionResponse?.name}]`)
|
||||
.join('\n');
|
||||
text = text ? `${text}\n${responses}` : responses;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import { Colors } from '../colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getSessionFiles,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { TabHeader, type Tab } from './shared/TabHeader.js';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
|
||||
|
||||
/**
|
||||
* Props for the main SessionBrowser component.
|
||||
@@ -41,6 +45,8 @@ export interface SessionBrowserState {
|
||||
// Data state
|
||||
/** All loaded sessions */
|
||||
sessions: SessionInfo[];
|
||||
/** Extension tabs */
|
||||
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
|
||||
/** Sessions after filtering and sorting */
|
||||
filteredAndSortedSessions: SessionInfo[];
|
||||
|
||||
@@ -55,6 +61,8 @@ export interface SessionBrowserState {
|
||||
scrollOffset: number;
|
||||
/** Terminal width for layout calculations */
|
||||
terminalWidth: number;
|
||||
/** Current active tab (0: CLI, 1+: External) */
|
||||
activeTab: number;
|
||||
|
||||
// Search state
|
||||
/** Current search query string */
|
||||
@@ -83,6 +91,10 @@ export interface SessionBrowserState {
|
||||
// State setters
|
||||
/** Update sessions array */
|
||||
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
|
||||
/** Update extensionTabs array */
|
||||
setExtensionTabs: React.Dispatch<
|
||||
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
|
||||
>;
|
||||
/** Update loading state */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Update error state */
|
||||
@@ -91,6 +103,8 @@ export interface SessionBrowserState {
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
/** Update scroll offset */
|
||||
setScrollOffset: React.Dispatch<React.SetStateAction<number>>;
|
||||
/** Update active tab */
|
||||
setActiveTab: (index: number) => void;
|
||||
/** Update search query */
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** Update search mode state */
|
||||
@@ -352,6 +366,9 @@ export const useSessionBrowserState = (
|
||||
): SessionBrowserState => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
|
||||
const [extensionTabs, setExtensionTabs] = useState<
|
||||
Array<{ name: string; sessions: SessionInfo[] }>
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(initialLoading);
|
||||
const [error, setError] = useState<string | null>(initialError);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
@@ -365,10 +382,20 @@ export const useSessionBrowserState = (
|
||||
const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false);
|
||||
const loadingFullContentRef = useRef(false);
|
||||
|
||||
const [activeTab, setActiveTabInternal] = useState(0);
|
||||
|
||||
const setActiveTab = useCallback((index: number) => {
|
||||
setActiveTabInternal(index);
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedSessions = useMemo(() => {
|
||||
const filtered = filterSessions(sessions, searchQuery);
|
||||
const currentTabSessions =
|
||||
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
|
||||
const filtered = filterSessions(currentTabSessions, searchQuery);
|
||||
return sortSessions(filtered, sortOrder, sortReverse);
|
||||
}, [sessions, searchQuery, sortOrder, sortReverse]);
|
||||
}, [sessions, extensionTabs, activeTab, searchQuery, sortOrder, sortReverse]);
|
||||
|
||||
// Reset full content flag when search is cleared
|
||||
useEffect(() => {
|
||||
@@ -386,6 +413,8 @@ export const useSessionBrowserState = (
|
||||
const state: SessionBrowserState = {
|
||||
sessions,
|
||||
setSessions,
|
||||
extensionTabs,
|
||||
setExtensionTabs,
|
||||
loading,
|
||||
setLoading,
|
||||
error,
|
||||
@@ -394,6 +423,8 @@ export const useSessionBrowserState = (
|
||||
setActiveIndex,
|
||||
scrollOffset,
|
||||
setScrollOffset,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
isSearchMode,
|
||||
@@ -415,12 +446,43 @@ export const useSessionBrowserState = (
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an external session info to CLI SessionInfo format.
|
||||
*/
|
||||
type ExternalSessionInfo = {
|
||||
id: string;
|
||||
mtime: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
messageCount?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
function convertExternalToSessionInfo(
|
||||
ext: ExternalSessionInfo,
|
||||
index: number,
|
||||
): SessionInfo {
|
||||
return {
|
||||
id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
|
||||
file: ext.id,
|
||||
fileName: ext.id + '.ext',
|
||||
startTime: ext.mtime,
|
||||
lastUpdated: ext.mtime,
|
||||
messageCount: ext.messageCount || 0,
|
||||
displayName: ext.displayName || ext.name || 'External Session',
|
||||
firstUserMessage: ext.displayName || ext.name || '',
|
||||
isCurrentSession: false,
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to load sessions on mount.
|
||||
*/
|
||||
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
const {
|
||||
setSessions,
|
||||
setExtensionTabs,
|
||||
setLoading,
|
||||
setError,
|
||||
isSearchMode,
|
||||
@@ -432,11 +494,58 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
const sessionData = await getSessionFiles(
|
||||
chatsDir,
|
||||
config.getSessionId(),
|
||||
);
|
||||
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
||||
|
||||
const externalTabs: Array<{ name: string; sessions: SessionInfo[] }> =
|
||||
[];
|
||||
if (config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
const extensions = (config as any)._extensionLoader?.getExtensions
|
||||
? (config as any)._extensionLoader.getExtensions()
|
||||
: [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.trajectoryProviderModule) {
|
||||
try {
|
||||
const sessions =
|
||||
await extension.trajectoryProviderModule.listSessions(
|
||||
workspaceUri,
|
||||
);
|
||||
const normalizedExt = sessions
|
||||
.map((ext: any) => ({
|
||||
...ext,
|
||||
prefix: extension.trajectoryProviderModule.prefix,
|
||||
}))
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
|
||||
)
|
||||
.map((ext: any, i: number) =>
|
||||
convertExternalToSessionInfo(ext, i + 1),
|
||||
);
|
||||
|
||||
if (normalizedExt.length > 0) {
|
||||
externalTabs.push({
|
||||
name:
|
||||
extension.trajectoryProviderModule.displayName ||
|
||||
extension.name,
|
||||
sessions: normalizedExt,
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore loader errors
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
const [sessionData] = await Promise.all([
|
||||
getSessionFiles(chatsDir, config.getSessionId()),
|
||||
]);
|
||||
|
||||
setSessions(sessionData);
|
||||
setExtensionTabs(externalTabs);
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -448,7 +557,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
loadSessions();
|
||||
}, [config, setSessions, setLoading, setError]);
|
||||
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFullContent = async () => {
|
||||
@@ -610,6 +719,9 @@ export const useSessionBrowserInput = (
|
||||
}
|
||||
// Delete session control.
|
||||
else if (key.sequence === 'x' || key.sequence === 'X') {
|
||||
// Only allow deleting CLI sessions for now
|
||||
if (state.activeTab !== 0) return true;
|
||||
|
||||
const selectedSession =
|
||||
state.filteredAndSortedSessions[state.activeIndex];
|
||||
if (selectedSession && !selectedSession.isCurrentSession) {
|
||||
@@ -684,6 +796,14 @@ export function SessionBrowserView({
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element {
|
||||
const tabs: Tab[] = [
|
||||
{ key: 'cli', header: 'Gemini CLI' },
|
||||
...state.extensionTabs.map((ext, i) => ({
|
||||
key: `ext-${i}`,
|
||||
header: ext.name,
|
||||
})),
|
||||
];
|
||||
|
||||
if (state.loading) {
|
||||
return <SessionBrowserLoading />;
|
||||
}
|
||||
@@ -692,11 +812,20 @@ export function SessionBrowserView({
|
||||
return <SessionBrowserError state={state} />;
|
||||
}
|
||||
|
||||
if (state.sessions.length === 0) {
|
||||
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
|
||||
return <SessionBrowserEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box marginBottom={1}>
|
||||
<TabHeader
|
||||
tabs={tabs}
|
||||
currentIndex={state.activeTab}
|
||||
showStatusIcons={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SessionListHeader state={state} />
|
||||
|
||||
{state.isSearchMode && <SearchModeDisplay state={state} />}
|
||||
@@ -721,6 +850,14 @@ export function SessionBrowser({
|
||||
useLoadSessions(config, state);
|
||||
const moveSelection = useMoveSelection(state);
|
||||
const cycleSortOrder = useCycleSortOrder(state);
|
||||
|
||||
useTabbedNavigation({
|
||||
tabCount: state.extensionTabs.length + 1,
|
||||
isActive: !state.isSearchMode,
|
||||
wrapAround: true,
|
||||
onTabChange: (index) => state.setActiveTab(index),
|
||||
});
|
||||
|
||||
useSessionBrowserInput(
|
||||
state,
|
||||
moveSelection,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SessionInfo,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
|
||||
@@ -51,20 +52,54 @@ export const useSessionBrowser = (
|
||||
handleResumeSession: useCallback(
|
||||
async (session: SessionInfo) => {
|
||||
try {
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
let conversation: ConversationRecord;
|
||||
let filePath: string;
|
||||
|
||||
const fileName = session.fileName;
|
||||
if (session.fileName.endsWith('.ext')) {
|
||||
// External session
|
||||
let externalConv: ConversationRecord | null = null;
|
||||
if (config.getEnableExtensionReloading() !== false) {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
const extensions = (config as any)._extensionLoader?.getExtensions
|
||||
? (config as any)._extensionLoader.getExtensions()
|
||||
: [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.trajectoryProviderModule) {
|
||||
const prefix =
|
||||
extension.trajectoryProviderModule.prefix || '';
|
||||
if (session.id.startsWith(prefix)) {
|
||||
const originalId = prefix
|
||||
? session.id.slice(prefix.length)
|
||||
: session.id;
|
||||
externalConv =
|
||||
await extension.trajectoryProviderModule.loadSession(
|
||||
originalId,
|
||||
);
|
||||
if (externalConv) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
const originalFilePath = path.join(chatsDir, fileName);
|
||||
if (!externalConv) {
|
||||
throw new Error(`Could not load external session ${session.id}`);
|
||||
}
|
||||
conversation = externalConv;
|
||||
filePath = session.id + '.ext';
|
||||
} else {
|
||||
// Regular CLI session
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const fileName = session.fileName;
|
||||
filePath = path.join(chatsDir, fileName);
|
||||
|
||||
// Load up the conversation.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const conversation: ConversationRecord = JSON.parse(
|
||||
await fs.readFile(originalFilePath, 'utf8'),
|
||||
);
|
||||
// Load up the conversation.
|
||||
|
||||
conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
// Use the old session's ID to continue it.
|
||||
const existingSessionId = conversation.sessionId;
|
||||
@@ -73,7 +108,7 @@ export const useSessionBrowser = (
|
||||
|
||||
const resumedSessionData = {
|
||||
conversation,
|
||||
filePath: originalFilePath,
|
||||
filePath,
|
||||
};
|
||||
|
||||
// We've loaded it; tell the UI about it.
|
||||
|
||||
Reference in New Issue
Block a user