feat(cli): integrate TrajectoryProvider into /resume UI

This commit is contained in:
Sehoon Shon
2026-03-19 00:27:24 -04:00
parent b75ec12185
commit 4f8534b223
6 changed files with 441 additions and 47 deletions
+27
View File
@@ -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,
};
}
+13
View File
@@ -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>',
});
});
});
});
+142 -10
View File
@@ -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,
+47 -12
View File
@@ -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.