feat(cli): add support for Antigravity (teleportation) sessions

This commit is contained in:
Sehoon Shon
2026-02-16 15:22:30 -05:00
parent 2009fbbd92
commit 71487c6fbe
8 changed files with 476 additions and 77 deletions

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,10 @@ import {
decodeTagName,
type MessageActionReturn,
INITIAL_HISTORY_LENGTH,
listAgySessions,
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
} from '@google/gemini-cli-core';
import path from 'node:path';
import type {
@@ -64,6 +69,24 @@ const getSavedChatTags = async (
: a.mtime.localeCompare(b.mtime),
);
// Also look for Antigravity sessions
const agySessions = await listAgySessions();
for (const agy of agySessions) {
chatDetails.push({
name: `agy:${agy.id}`,
mtime: agy.mtime,
});
}
// Re-sort if we added AGY sessions
if (agySessions.length > 0) {
chatDetails.sort((a, b) =>
mtSortDesc
? b.mtime.localeCompare(a.mtime)
: a.mtime.localeCompare(b.mtime),
);
}
return chatDetails;
} catch (_err) {
return [];
@@ -174,8 +197,98 @@ const resumeCheckpointCommand: SlashCommand = {
const { logger, config } = context.services;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
let conversation: Content[] = [];
let authType: string | undefined;
const loadAgy = async (id: string): Promise<Content[] | null> => {
const data = await loadAgySession(id);
if (!data) return null;
const agyJson = trajectoryToJson(data);
const record = convertAgyToCliRecord(agyJson);
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) => 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;
};
if (tag.startsWith('agy:')) {
const id = tag.slice(4);
const agyConv = await loadAgy(id);
if (!agyConv) {
return {
type: 'message',
messageType: 'error',
content: `No Antigravity session found with id: ${id}`,
};
}
conversation = agyConv;
} else {
const checkpoint = await logger.loadCheckpoint(tag);
if (checkpoint.history.length > 0) {
conversation = checkpoint.history;
authType = checkpoint.authType;
} else {
// Fallback: Try to load as AGY session even without prefix
const agyConv = await loadAgy(tag);
if (agyConv) {
conversation = agyConv;
}
}
}
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;
}

View File

@@ -12,12 +12,18 @@ 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 type { SessionInfo } from '../../utils/sessionUtils.js';
import {
listAgySessions,
type Config,
type AgySessionInfo,
} from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } 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';
/**
* Props for the main SessionBrowser component.
@@ -41,6 +47,8 @@ export interface SessionBrowserState {
// Data state
/** All loaded sessions */
sessions: SessionInfo[];
/** Antigravity sessions */
agySessions: SessionInfo[];
/** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[];
@@ -55,6 +63,8 @@ export interface SessionBrowserState {
scrollOffset: number;
/** Terminal width for layout calculations */
terminalWidth: number;
/** Current active tab (0: CLI, 1: Antigravity) */
activeTab: number;
// Search state
/** Current search query string */
@@ -83,6 +93,8 @@ export interface SessionBrowserState {
// State setters
/** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update agySessions array */
setAgySessions: React.Dispatch<React.SetStateAction<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,7 @@ export const useSessionBrowserState = (
): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize();
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
const [agySessions, setAgySessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(initialLoading);
const [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0);
@@ -365,10 +380,19 @@ 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 : agySessions;
const filtered = filterSessions(currentTabSessions, searchQuery);
return sortSessions(filtered, sortOrder, sortReverse);
}, [sessions, searchQuery, sortOrder, sortReverse]);
}, [sessions, agySessions, activeTab, searchQuery, sortOrder, sortReverse]);
// Reset full content flag when search is cleared
useEffect(() => {
@@ -386,6 +410,8 @@ export const useSessionBrowserState = (
const state: SessionBrowserState = {
sessions,
setSessions,
agySessions,
setAgySessions,
loading,
setLoading,
error,
@@ -394,6 +420,8 @@ export const useSessionBrowserState = (
setActiveIndex,
scrollOffset,
setScrollOffset,
activeTab,
setActiveTab,
searchQuery,
setSearchQuery,
isSearchMode,
@@ -415,12 +443,34 @@ export const useSessionBrowserState = (
return state;
};
/**
* Converts Antigravity session info to CLI SessionInfo format.
*/
function convertAgyToSessionInfo(
agy: AgySessionInfo,
index: number,
): SessionInfo {
return {
id: agy.id,
file: agy.id,
fileName: agy.id + '.pb',
startTime: agy.mtime,
lastUpdated: agy.mtime,
messageCount: agy.messageCount || 0,
displayName: agy.displayName || 'Antigravity Session',
firstUserMessage: agy.displayName || '',
isCurrentSession: false,
index,
};
}
/**
* Hook to load sessions on mount.
*/
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const {
setSessions,
setAgySessions,
setLoading,
setError,
isSearchMode,
@@ -432,11 +482,20 @@ 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 [sessionData, agyData] = await Promise.all([
getSessionFiles(chatsDir, config.getSessionId()),
listAgySessions(),
]);
setSessions(sessionData);
const normalizedAgy = agyData
.sort(
(a, b) => new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
)
.map((agy, i) => convertAgyToSessionInfo(agy, i + 1));
setAgySessions(normalizedAgy);
setLoading(false);
} catch (err) {
setError(
@@ -448,7 +507,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadSessions();
}, [config, setSessions, setLoading, setError]);
}, [config, setSessions, setAgySessions, setLoading, setError]);
useEffect(() => {
const loadFullContent = async () => {
@@ -610,6 +669,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 +746,11 @@ export function SessionBrowserView({
}: {
state: SessionBrowserState;
}): React.JSX.Element {
const tabs: Tab[] = [
{ key: 'cli', header: 'CLI Sessions' },
{ key: 'agy', header: 'Antigravity' },
];
if (state.loading) {
return <SessionBrowserLoading />;
}
@@ -692,11 +759,20 @@ export function SessionBrowserView({
return <SessionBrowserError state={state} />;
}
if (state.sessions.length === 0) {
if (state.sessions.length === 0 && state.agySessions.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 +797,13 @@ export function SessionBrowser({
useLoadSessions(config, state);
const moveSelection = useMoveSelection(state);
const cycleSortOrder = useCycleSortOrder(state);
useTabbedNavigation({
tabCount: 2,
isActive: !state.isSearchMode,
onTabChange: (index) => state.setActiveTab(index),
});
useSessionBrowserInput(
state,
moveSelection,

View File

@@ -9,6 +9,9 @@ import type { HistoryItemWithoutId } from '../types.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
coreEvents,
convertSessionToClientHistory,
uiTelemetryService,
@@ -51,20 +54,35 @@ 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('.pb')) {
// Antigravity session
const data = await loadAgySession(session.id);
if (!data) {
throw new Error(
`Could not load Antigravity session ${session.id}`,
);
}
const json = trajectoryToJson(data);
conversation = convertAgyToCliRecord(json);
// Antigravity sessions don't have a local CLI file path yet,
// but we'll use the .pb path for reference in resumedSessionData
filePath = session.id + '.pb';
} else {
// Regular CLI session
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const fileName = session.fileName;
filePath = path.join(chatsDir, fileName);
const originalFilePath = 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.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
}
// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
@@ -73,7 +91,7 @@ export const useSessionBrowser = (
const resumedSessionData = {
conversation,
filePath: originalFilePath,
filePath,
};
// We've loaded it; tell the UI about it.